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; }