mc-dashboard/app/api/schedule/tasks/route.ts
hurkicorgi cf467b26c7 Expanded scheduled tasks + keyboard shortcuts
- 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>
2026-04-13 05:57:39 -06:00

206 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 1120 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 150" },
{ 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;
}