Files
STCourses/packages/feature-import/src/routes.ts
T
Lucas WintherandClaude Opus 5.5 777ae2cc37 feat: resolve Phase 1 contract requests and compose every feature
Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
  context, runs the factories, then the job queue from the union of their jobs
  (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
  <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
  importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
  resolveConfig derives the same for tests; cachePaths shares the importer's
  cache layout.
- canView: the global author role views every course (R14); the learner,
  studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
  an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.

Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
  refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
  the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
  draft URLs); AI drafts go to the studio draft branch for studio courses (as
  the requesting user) and to <dataDir>/drafts otherwise, and the generator
  page says which.

Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.

Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:02:46 +01:00

115 lines
5.9 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, 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<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);
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);
}),
},
};
}