207 lines
5.9 KiB
TypeScript
207 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;
|
|||
|
|
}
|