- scripts/run-task.sh: dispatcher for say / backup / snapshot-prune with
safe arg handling (message stripped of CR/LF, integer-clamped keep
count). Logs to ~/logs/mc-dashboard/tasks.log (falls back to /tmp).
- New /api/schedule/tasks GET/POST/DELETE route: stores tasks as
crontab lines with `# mc-task:<base64(json)>` marker so the UI can
round-trip them. Strict server-side validation:
- Cron expression regex (5 fields, * / N / N-N / N,N / */N)
- say message: 1–120 chars, no newlines/backticks/shell quotes
- snapshot-prune keep: integer 1–50
- task id: 16-hex only
Single-quote-escaped message in the generated shell command.
- ScheduledTasks UI under ServerControls (alongside the existing single
ScheduledRestart): pick type (Announce / Backup / Prune snapshots),
preset schedule (daily at HH:MM or every N hours), adds with one
click. Tasks list shows human-readable schedule + "next in Xh" hint
computed client-side. Hover-reveal Remove action.
- Admin keyboard shortcuts: when not typing,
r — refetch the active tab's query keys (toast feedback)
/ — focus the first input/contenteditable in the active panel
? — toast the shortcuts cheat sheet
Chord-free, mirrors existing ⌘K palette and Esc handlers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
5.9 KiB
TypeScript
206 lines
5.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import { execSync } from "child_process";
|
||
import { writeFileSync, unlinkSync } from "fs";
|
||
import { randomBytes } from "crypto";
|
||
import { auth } from "@/lib/auth";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const TASK_MARKER = "# mc-task:";
|
||
const RUN_SCRIPT = "/home/minecraft/dashboard/scripts/run-task.sh";
|
||
|
||
const TASK_TYPES = ["say", "backup", "snapshot-prune"] as const;
|
||
type TaskType = (typeof TASK_TYPES)[number];
|
||
|
||
type Task = {
|
||
id: string;
|
||
type: TaskType;
|
||
label: string;
|
||
cron: string; // "M H D M W"
|
||
params: {
|
||
message?: string;
|
||
keep?: number;
|
||
};
|
||
};
|
||
|
||
const MSG_RE = /^[^\r\n$`\\'"]{1,120}$/;
|
||
|
||
function isValidCron(cron: string): boolean {
|
||
const parts = cron.trim().split(/\s+/);
|
||
if (parts.length !== 5) return false;
|
||
const field = /^(\*|\*\/\d+|\d+(?:[,/\-]\d+)*(?:,\d+(?:[,/\-]\d+)*)*)$/;
|
||
return parts.every((p) => field.test(p));
|
||
}
|
||
|
||
function escapeSingle(s: string): string {
|
||
return s.replace(/'/g, "'\\''");
|
||
}
|
||
|
||
function buildCommand(task: Task): string {
|
||
const base = `bash ${RUN_SCRIPT}`;
|
||
if (task.type === "say") {
|
||
const msg = (task.params.message || "").trim();
|
||
return `${base} say '${escapeSingle(msg)}'`;
|
||
}
|
||
if (task.type === "snapshot-prune") {
|
||
const keep = Math.min(
|
||
Math.max(1, Math.floor(Number(task.params.keep) || 5)),
|
||
50
|
||
);
|
||
return `${base} snapshot-prune ${keep}`;
|
||
}
|
||
return `${base} backup`;
|
||
}
|
||
|
||
function encodeTask(task: Task): string {
|
||
return Buffer.from(JSON.stringify(task), "utf8").toString("base64");
|
||
}
|
||
function decodeTask(b64: string): Task | null {
|
||
try {
|
||
const obj = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
|
||
if (
|
||
!obj ||
|
||
typeof obj.id !== "string" ||
|
||
!TASK_TYPES.includes(obj.type) ||
|
||
typeof obj.cron !== "string" ||
|
||
typeof obj.label !== "string"
|
||
) {
|
||
return null;
|
||
}
|
||
return obj as Task;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function readCrontab(): string {
|
||
try {
|
||
return execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function writeCrontab(contents: string): void {
|
||
const tmp = `/tmp/crontab-${Date.now()}-${randomBytes(3).toString("hex")}.tmp`;
|
||
writeFileSync(tmp, contents.endsWith("\n") ? contents : contents + "\n", {
|
||
mode: 0o600,
|
||
});
|
||
try {
|
||
execSync(`crontab ${tmp}`, { encoding: "utf8" });
|
||
} finally {
|
||
try { unlinkSync(tmp); } catch {}
|
||
}
|
||
}
|
||
|
||
function parseTasks(): Task[] {
|
||
const lines = readCrontab().split("\n");
|
||
const tasks: Task[] = [];
|
||
for (const line of lines) {
|
||
const idx = line.indexOf(TASK_MARKER);
|
||
if (idx < 0) continue;
|
||
const b64 = line.slice(idx + TASK_MARKER.length).trim();
|
||
const task = decodeTask(b64);
|
||
if (task) tasks.push(task);
|
||
}
|
||
return tasks;
|
||
}
|
||
|
||
export async function GET() {
|
||
const session = await auth();
|
||
if (!session) {
|
||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||
}
|
||
return NextResponse.json(parseTasks());
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const session = await auth();
|
||
if (!session) {
|
||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||
}
|
||
|
||
const body = await req.json();
|
||
const type = body.type as TaskType;
|
||
const label = typeof body.label === "string" ? body.label.trim().slice(0, 80) : "";
|
||
const cron = typeof body.cron === "string" ? body.cron.trim() : "";
|
||
const params = body.params || {};
|
||
|
||
if (!TASK_TYPES.includes(type)) {
|
||
return NextResponse.json({ error: "Invalid task type" }, { status: 400 });
|
||
}
|
||
if (!isValidCron(cron)) {
|
||
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
|
||
}
|
||
if (type === "say") {
|
||
if (typeof params.message !== "string" || !MSG_RE.test(params.message)) {
|
||
return NextResponse.json(
|
||
{ error: "Message must be 1–120 chars, no quotes or newlines" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
}
|
||
if (type === "snapshot-prune") {
|
||
const keep = Number(params.keep);
|
||
if (!Number.isInteger(keep) || keep < 1 || keep > 50) {
|
||
return NextResponse.json(
|
||
{ error: "keep must be an integer 1–50" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
}
|
||
|
||
const task: Task = {
|
||
id: randomBytes(8).toString("hex"),
|
||
type,
|
||
label: label || defaultLabel(type, params),
|
||
cron,
|
||
params: {
|
||
message: typeof params.message === "string" ? params.message : undefined,
|
||
keep: Number.isInteger(Number(params.keep)) ? Number(params.keep) : undefined,
|
||
},
|
||
};
|
||
|
||
try {
|
||
const cmd = buildCommand(task);
|
||
const line = `${cron} ${cmd} ${TASK_MARKER}${encodeTask(task)}`;
|
||
const crontab = readCrontab();
|
||
const updated = (crontab ? crontab.replace(/\n+$/, "") + "\n" : "") + line + "\n";
|
||
writeCrontab(updated);
|
||
return NextResponse.json(task);
|
||
} catch (e) {
|
||
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
export async function DELETE(req: NextRequest) {
|
||
const session = await auth();
|
||
if (!session) {
|
||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||
}
|
||
const { id } = await req.json();
|
||
if (typeof id !== "string" || !/^[a-f0-9]{16}$/.test(id)) {
|
||
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
const lines = readCrontab().split("\n");
|
||
const kept = lines.filter((line) => {
|
||
const idx = line.indexOf(TASK_MARKER);
|
||
if (idx < 0) return true;
|
||
const task = decodeTask(line.slice(idx + TASK_MARKER.length).trim());
|
||
return !task || task.id !== id;
|
||
});
|
||
writeCrontab(kept.filter(Boolean).join("\n"));
|
||
return NextResponse.json({ ok: true });
|
||
} catch (e) {
|
||
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
function defaultLabel(type: TaskType, params: { message?: string; keep?: number }): string {
|
||
if (type === "say") return `Announce: ${(params.message || "").slice(0, 40)}`;
|
||
if (type === "backup") return "World backup";
|
||
if (type === "snapshot-prune") return `Prune snapshots (keep ${params.keep || 5})`;
|
||
return type;
|
||
}
|