- Media exclusions move from the editable root .gitignore to the repo's info/exclude, and dirtyFiles never counts files listed in media.json, so an author's or remote's .gitignore cannot block publish or Sync. Publish and Sync refuse commits that track a file over referenced media. - Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run under the course lock now shared with the studio (core withCourseLock) and re-check the course inside it; after conversion they fail with "this course is now edited in the studio". Convert answers 409 while an import job for the course is queued or running, and the job re-checks. - Crash-safe conversion: the import folder is hidden (.converting-<id>) before the published worktree exists; cross-filesystem moves copy into a staging dir, fsync, verify size and sha256, then rename; the source is retired as .converted-<id>.<n>. Recovery runs at server start and before every convert; a re-run of a finished conversion reports success. The loader skips dot-folders. - Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK, referenced media are not read whole by the editor, missing media are logged, mediaDir JSDoc, docs on per-author copies across filesystems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
6.1 KiB
TypeScript
120 lines
6.1 KiB
TypeScript
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, saveManifest, STUDIO_OWNED, withManagedCourse, 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<string, unknown> = {};
|
|
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);
|
|
try {
|
|
await withManagedCourse(d, rc.params.courseId, async (course) => {
|
|
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);
|
|
});
|
|
} catch (e) {
|
|
const msg = (e as Error).message;
|
|
return json({ error: msg }, msg === STUDIO_OWNED ? 409 : 404);
|
|
}
|
|
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);
|
|
}),
|
|
},
|
|
};
|
|
}
|