feat(core): config, job queue, range media serving, feature composition
Adds aclRepo to @stc/db for canView. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea18f18b4f
commit
7f33169487
12 files changed
+533
No files matched your search
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AppConfig } from "@stc/contracts";
|
||||
|
||||
const DEV_SECRET = "stc-dev-secret-change-me";
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): AppConfig {
|
||||
const port = env.STC_PORT !== undefined && env.STC_PORT !== "" ? Number(env.STC_PORT) : 3000;
|
||||
if (!Number.isInteger(port) || port < 0) throw new Error(`Invalid STC_PORT: ${env.STC_PORT}`);
|
||||
let secret = env.STC_SECRET;
|
||||
if (!secret) {
|
||||
if (env.NODE_ENV === "production") throw new Error("STC_SECRET is required when NODE_ENV=production");
|
||||
console.warn("[stc] STC_SECRET is not set; using an insecure development default");
|
||||
secret = DEV_SECRET;
|
||||
}
|
||||
return {
|
||||
port,
|
||||
databaseUrl: env.STC_DATABASE_URL || "sqlite://./data/stcourses.db",
|
||||
dataDir: env.STC_DATA_DIR || "./data",
|
||||
courseDirs: (env.STC_COURSE_DIRS || "./overlays:./courses").split(":").filter(Boolean),
|
||||
secret,
|
||||
publicUrl: env.STC_PUBLIC_URL || `http://localhost:${port}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { loadConfig } from "./config";
|
||||
export { createJobQueue } from "./jobs";
|
||||
export { serveMedia, contentType, contentDisposition } from "./media";
|
||||
export { createApp, canView, type Routes } from "./app";
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { JobHandler, JobQueue } from "@stc/contracts";
|
||||
import { jobsRepo, type Db } from "@stc/db";
|
||||
|
||||
export function createJobQueue(db: Db, handlers: Record<string, JobHandler>, opts: { pollMs?: number } = {}): JobQueue & { start(): void; stop(): Promise<void> } {
|
||||
const repo = jobsRepo(db);
|
||||
const pollMs = opts.pollMs ?? 500;
|
||||
const kinds = Object.keys(handlers);
|
||||
let running = false;
|
||||
let loop: Promise<void> | null = null;
|
||||
let wake: (() => void) | null = null;
|
||||
let abort = new AbortController();
|
||||
|
||||
const runOne = async (): Promise<boolean> => {
|
||||
const job = await repo.claimNext(kinds);
|
||||
if (!job) return false;
|
||||
const jc = {
|
||||
signal: abort.signal,
|
||||
reportProgress: (fraction: number, message?: string) =>
|
||||
repo.update(job.id, message === undefined ? { progress: fraction } : { progress: fraction, message }),
|
||||
};
|
||||
try {
|
||||
const result = await handlers[job.kind]!(job.payload, jc);
|
||||
await repo.update(job.id, { status: "done", progress: 1, result: result ?? null });
|
||||
} catch (e) {
|
||||
await repo.update(job.id, { status: "failed", error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const sleep = () => new Promise<void>((res) => {
|
||||
const t = setTimeout(res, pollMs);
|
||||
wake = () => { clearTimeout(t); res(); };
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
while (running) {
|
||||
let did = false;
|
||||
try { did = await runOne(); } catch (e) { console.error("[stc] job worker error", e); }
|
||||
if (!did && running) await sleep();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async enqueue(kind, payload) {
|
||||
const id = await repo.insert(kind, payload);
|
||||
wake?.();
|
||||
return id;
|
||||
},
|
||||
get: (id) => repo.get(id),
|
||||
list: (kind) => repo.list(kind),
|
||||
start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
abort = new AbortController();
|
||||
loop = (async () => {
|
||||
// jobs left running by a previous process go back to the queue
|
||||
await db.sql`UPDATE jobs SET status = 'queued', updated_at = ${new Date().toISOString()} WHERE status = 'running'`;
|
||||
await run();
|
||||
})();
|
||||
},
|
||||
async stop() {
|
||||
running = false;
|
||||
abort.abort();
|
||||
wake?.();
|
||||
await loop;
|
||||
loop = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const TYPES: Record<string, string> = {
|
||||
mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
||||
svg: "image/svg+xml", md: "text/markdown; charset=utf-8", txt: "text/plain; charset=utf-8", zip: "application/zip",
|
||||
js: "text/javascript; charset=utf-8", ts: "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
export function contentType(path: string): string {
|
||||
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
||||
return TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** RFC 6266 / 5987 header value: ASCII fallback plus UTF-8 filename*. */
|
||||
export function contentDisposition(name: string, kind: "inline" | "attachment" = "attachment"): string {
|
||||
const fallback = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
||||
const star = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
return `${kind}; filename="${fallback}"; filename*=UTF-8''${star}`;
|
||||
}
|
||||
|
||||
function parseRange(header: string, size: number): { start: number; end: number } | "invalid" | null {
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!m) return null; // malformed or multi-range: ignore, serve whole file
|
||||
const [, a, b] = m;
|
||||
if (a === "" && b === "") return null;
|
||||
let start: number, end: number;
|
||||
if (a === "") {
|
||||
const n = Number(b);
|
||||
if (n === 0) return "invalid";
|
||||
start = Math.max(0, size - n); end = size - 1;
|
||||
} else {
|
||||
start = Number(a);
|
||||
end = b === "" ? size - 1 : Math.min(Number(b), size - 1);
|
||||
if (start >= size || start > end) return "invalid";
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export async function serveMedia(req: Request, absPath: string, opts: { download?: boolean } = {}): Promise<Response> {
|
||||
const file = Bun.file(absPath);
|
||||
if (!(await file.exists())) return new Response("Not found", { status: 404 });
|
||||
const size = file.size;
|
||||
const name = absPath.split("/").pop() ?? "file";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType(absPath),
|
||||
"Accept-Ranges": "bytes",
|
||||
};
|
||||
if (opts.download) headers["Content-Disposition"] = contentDisposition(name);
|
||||
const range = req.headers.get("range");
|
||||
const parsed = range ? parseRange(range, size) : null;
|
||||
if (parsed === "invalid") return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes" } });
|
||||
if (!parsed) {
|
||||
headers["Content-Length"] = String(size);
|
||||
return new Response(req.method === "HEAD" ? null : file, { status: 200, headers });
|
||||
}
|
||||
headers["Content-Range"] = `bytes ${parsed.start}-${parsed.end}/${size}`;
|
||||
headers["Content-Length"] = String(parsed.end - parsed.start + 1);
|
||||
return new Response(req.method === "HEAD" ? null : file.slice(parsed.start, parsed.end + 1), { status: 206, headers });
|
||||
}
|
||||
Reference in new issue
Block a user