Adds aclRepo to @stc/db for canView. Co-Authored-By: Claude Opus 5.5 <[email protected]>
100 lines
4.5 KiB
TypeScript
100 lines
4.5 KiB
TypeScript
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<string, Partial<Record<Method, (req: Request) => Response | Promise<Response>>>>;
|
|
|
|
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<Response> } {
|
|
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<string, string> }): Promise<Response> => {
|
|
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<string, string> = {};
|
|
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<typeof match>;
|
|
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<string, string> };
|
|
r.params = m.params;
|
|
return h(r);
|
|
},
|
|
};
|
|
}
|