import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts"; import type { LoadedCourse } from "@stc/course-format"; import { aclRepo } from "@stc/db"; import { serveMedia } from "./media"; type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type Routes = Record Response | Promise>>>; export function canView(user: AuthUser | null, course: LoadedCourse, acl: { principal_type: string; principal_id: string }[], isAuthor = false): boolean { const vis = course.manifest?.visibility ?? "restricted"; if (vis === "public") return true; if (!user) return false; if (vis === "signed-in") return true; if (user.roles.includes("owner") || isAuthor) return true; return acl.some((a) => (a.principal_type === "user" && a.principal_id === user.id) || (a.principal_type === "group" && user.groups.includes(a.principal_id))); } const json = (body: unknown, status = 200) => Response.json(body, { status }); export function createApp(ctx: ServerContext, features: Feature[]): { routes: Routes; fetch(req: Request): Promise } { const acl = aclRepo(ctx.db); const core: Feature = { name: "core", routes: { "/api/health": { GET: () => json({ ok: true, courses: ctx.courses.list().length, db: ctx.db.dialect }) }, "/api/media/:courseId/*": { GET: async (req, rc) => { // Parse from the pathname so we do not depend on how the server fills params for "*". const m = /^\/api\/media\/([^/]+)\/(.*)$/s.exec(rc.url.pathname); if (!m) return new Response("Not found", { status: 404 }); let courseId: string, rel: string; try { courseId = decodeURIComponent(m[1]!); rel = decodeURIComponent(m[2]!); } catch { return new Response("Not found", { status: 404 }); } const course = ctx.courses.get(courseId); if (!course) return new Response("Not found", { status: 404 }); const visible = canView(rc.user, course, await acl.listForCourse(course.id), rc.user ? await acl.isAuthor(course.id, rc.user.id) : false); if (!visible) return new Response("Not found", { status: 404 }); const abs = ctx.courses.resolvePath(course.id, rel); if (!abs) return new Response("Not found", { status: 404 }); return serveMedia(req, abs, { download: rc.url.searchParams.has("download") }); }, }, }, }; const wrap = (h: RouteHandler) => async (req: Request & { params?: Record }): Promise => { const url = new URL(req.url); const rc: RequestContext = { user: await ctx.auth.userFromRequest(req), params: req.params ?? {}, url }; return h(req, rc); }; const routes: Routes = {}; for (const f of [core, ...features]) { for (const [path, methods] of Object.entries(f.routes)) { const slot = (routes[path] ??= {}); for (const [m, h] of Object.entries(methods) as [Method, RouteHandler][]) { if (slot[m]) throw new Error(`Duplicate route ${m} ${path} (feature "${f.name}")`); slot[m] = wrap(h); } } } // Matcher for fetch(): same semantics as Bun routes (":name" segment, trailing "*" wildcard). const compiled = Object.entries(routes).map(([path, methods]) => { const parts = path.split("/"); return { parts, methods }; }); const match = (pathname: string) => { const segs = pathname.split("/"); for (const { parts, methods } of compiled) { const params: Record = {}; let ok = true; for (let i = 0; i < parts.length && ok; i++) { const p = parts[i]!; if (p === "*") { params["*"] = segs.slice(i).join("/"); if (i >= segs.length) params["*"] = ""; return { methods, params }; } if (segs[i] === undefined) ok = false; else if (p.startsWith(":")) params[p.slice(1)] = decodeURIComponent(segs[i]!); else if (p !== segs[i]) ok = false; } if (ok && parts.length === segs.length) return { methods, params }; } return null; }; return { routes, async fetch(req) { const url = new URL(req.url); let m: ReturnType; try { m = match(url.pathname); } catch { return new Response("Bad request", { status: 400 }); } if (!m) return new Response("Not found", { status: 404 }); const h = m.methods[req.method as Method]; if (!h) return new Response("Method not allowed", { status: 405 }); const r = req as Request & { params?: Record }; r.params = m.params; return h(r); }, }; }