import { copyFile, mkdir, readdir } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import type { Feature, RouteHandler } from "@stc/contracts"; import { serveMedia } from "@stc/core"; import { assertAllowedPath, importRoots, OUTSIDE } from "./roots"; import { coverCacheDir, coursesDir, exists, isPlainId, managedCourse, saveManifest, type Deps } from "./context"; export const KINDS = ["packt-video", "code-repo", "pdf-deck", "video-folder"] as const; const OPTION_KEYS = ["title", "courseId", "attachTo", "media", "transcribe"] as const; const COVER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*\.jpg$/; const GIT_URL = /^(https?:\/\/|ssh:\/\/|git@)\S+$/; const json = (body: unknown, status = 200) => Response.json(body, { status }); export function importRoutes(deps: () => Deps): Feature["routes"] { /** Imports read arbitrary server paths, so every route needs the owner role. */ const owner = (h: RouteHandler): RouteHandler => (req, rc) => { if (!rc.user) return json({ error: "Sign in required" }, 401); if (!rc.user.roles.includes("owner")) return json({ error: "Only the owner can import" }, 403); return h(req, rc); }; return { "/api/import/info": { GET: owner(() => { const d = deps(); const dir = coursesDir(d); const served = d.ctx.config.courseDirs.some((c) => resolve(c) === dir); return json({ transcribe: Boolean(d.transcribeUrl), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) }); }), }, "/api/import/jobs": { GET: owner(async () => { const jobs = (await deps().ctx.jobs.list()).filter((j) => j.kind.startsWith("import.")); return json(jobs); }), POST: owner(async (req) => { const d = deps(); let body: any; try { body = await req.json(); } catch { return json({ error: "Body must be JSON" }, 400); } const kind = String(body?.kind ?? ""); const options: Record = {}; for (const k of OPTION_KEYS) if (body?.options?.[k] !== undefined && body.options[k] !== "") options[k] = body.options[k]; if (kind === "transcribe") { if (!d.transcribeUrl) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409); if (typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400); return json({ id: await d.ctx.jobs.enqueue("import.transcribe", { courseId: options.courseId }) }, 202); } if (!(KINDS as readonly string[]).includes(kind)) return json({ error: `kind must be one of ${KINDS.join(", ")}` }, 400); const path = typeof body?.path === "string" ? body.path.trim() : ""; if (!path) return json({ error: "path is required" }, 400); const isGit = kind === "code-repo" && GIT_URL.test(path); if (!isGit) { if (!path.startsWith("/")) return json({ error: "Use an absolute path" }, 400); try { await assertAllowedPath(d, path); } catch { return json({ error: OUTSIDE }, 400); } } if ((kind === "code-repo" || kind === "pdf-deck") && typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400); if (kind === "pdf-deck" && typeof options.attachTo !== "string") return json({ error: "attachTo is required" }, 400); return json({ id: await d.ctx.jobs.enqueue(`import.${kind}`, { kind, path, options }) }, 202); }), }, "/api/import/courses": { GET: owner(() => { const d = deps(); const base = coursesDir(d); const out = d.ctx.courses.list() .filter((c) => c.manifest && !relative(base, c.root).startsWith("..")) .map((c) => ({ id: c.id, title: c.manifest!.title, modules: c.manifest!.modules.map((m) => ({ id: m.id, title: m.title, lessons: m.lessons.map((l) => ({ id: l.id, title: l.title })) })), })); return json(out); }), }, "/api/import/covers/:courseId": { GET: owner(async (_req, rc) => { const d = deps(); const id = rc.params.courseId!; if (!isPlainId(id)) return json({ error: "Invalid course id" }, 400); const c = d.ctx.courses.get(id); const names = (await readdir(coverCacheDir(d, id)).catch(() => [] as string[])).filter((n) => COVER_NAME.test(n)).sort(); return json({ current: c?.manifest?.cover ?? null, candidates: names }); }), POST: owner(async (req, rc) => { const d = deps(); let body: any; try { body = await req.json(); } catch { return json({ error: "Body must be JSON" }, 400); } const name = String(body?.name ?? ""); if (!COVER_NAME.test(name)) return json({ error: "Invalid candidate name" }, 400); if (!isPlainId(rc.params.courseId!)) return json({ error: "Invalid course id" }, 400); const src = join(coverCacheDir(d, rc.params.courseId!), name); if (!(await exists(src))) return json({ error: "No such candidate" }, 404); let course; try { course = await managedCourse(d, rc.params.courseId); } catch (e) { return json({ error: (e as Error).message }, 404); } await mkdir(join(course.dir, "media"), { recursive: true }); await copyFile(src, join(course.dir, "media", "cover.jpg")); course.manifest.cover = "media/cover.jpg"; await saveManifest(course.dir, course.manifest); await d.ctx.courses.reload(course.id); return json({ cover: "media/cover.jpg" }); }), }, "/api/import/covers/:courseId/:name": { GET: owner(async (req, rc) => { const name = rc.params.name!; if (!COVER_NAME.test(name)) return new Response("Not found", { status: 404 }); if (!isPlainId(rc.params.courseId!)) return new Response("Not found", { status: 404 }); const file = join(coverCacheDir(deps(), rc.params.courseId!), name); if (!(await exists(file))) return new Response("Not found", { status: 404 }); return serveMedia(req, file); }), }, }; }