feat(core): config, job queue, range media serving, feature composition
Adds aclRepo to @stc/db for canView. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ea18f18b4f
commit
7f33169487
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { cpSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import { canView, createApp } from "../src";
|
||||
|
||||
const SAMPLE = resolve(import.meta.dir, "../../../fixtures/courses/sample");
|
||||
|
||||
async function setup(user: AuthUser | null = null) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-app-"));
|
||||
cpSync(SAMPLE, join(dir, "pub"), { recursive: true });
|
||||
const manifest = await Bun.file(join(SAMPLE, "stcourse.yaml")).text();
|
||||
mkdirSync(join(dir, "priv"));
|
||||
cpSync(SAMPLE, join(dir, "priv"), { recursive: true });
|
||||
writeFileSync(join(dir, "priv/stcourse.yaml"), manifest.replace("id: sample", "id: secret").replace("visibility: public", "visibility: restricted"));
|
||||
mkdirSync(join(dir, "broken"));
|
||||
writeFileSync(join(dir, "broken/stcourse.yaml"), "schema: 1\nid: broken\ntitle: Broken\nroot: /does/not/exist\nmodules: []\n");
|
||||
symlinkSync("/etc", join(dir, "pub/escape"));
|
||||
const db = await connect("sqlite://:memory:");
|
||||
await migrate(db);
|
||||
const courses = await createCourseRepository([dir]);
|
||||
const ctx = { db, courses, auth: { userFromRequest: async () => user } } as unknown as ServerContext;
|
||||
return { ctx, db, dir };
|
||||
}
|
||||
const feat = (name: string): Feature => ({ name, routes: { "/api/x": { GET: () => new Response(name) } } });
|
||||
const user = (over: Partial<AuthUser> = {}): AuthUser => ({ id: "u1", email: "u@x", name: "U", roles: ["learner"], groups: [], ...over });
|
||||
|
||||
test("health ok, and an error course does not break it", async () => {
|
||||
const { ctx, db } = await setup();
|
||||
expect(ctx.courses.get("broken")?.status).toBe("error");
|
||||
const app = createApp(ctx, []);
|
||||
const r = await app.fetch(new Request("http://x/api/health"));
|
||||
expect(r.status).toBe(200);
|
||||
expect(await r.json()).toEqual({ ok: true, courses: ctx.courses.list().length, db: "sqlite" });
|
||||
expect(ctx.courses.list().length).toBe(3);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("media serves a file with Range, and a missing/broken course is 404", async () => {
|
||||
const { ctx, db } = await setup();
|
||||
const app = createApp(ctx, []);
|
||||
const r = await app.fetch(new Request("http://x/api/media/sample/media/clip.mp4", { headers: { Range: "bytes=0-9" } }));
|
||||
expect(r.status).toBe(206);
|
||||
expect((await app.fetch(new Request("http://x/api/media/broken/anything"))).status).toBe(404);
|
||||
expect((await app.fetch(new Request("http://x/api/media/nope/a"))).status).toBe(404);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("traversal, absolute and symlink escapes are 404", async () => {
|
||||
const { ctx, db } = await setup();
|
||||
const app = createApp(ctx, []);
|
||||
for (const p of ["..%2F..%2Fetc%2Fpasswd", "%2Fetc%2Fpasswd", "escape/passwd", "../priv/stcourse.yaml", "%E0%A4%A"]) {
|
||||
const r = await app.fetch(new Request(`http://x/api/media/sample/${p}`));
|
||||
expect(r.status).toBe(404);
|
||||
}
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("restricted course: 404 anonymous, visible to owner, author, ACL user and group", async () => {
|
||||
const url = "http://x/api/media/secret/media/clip.mp4";
|
||||
const run = async (u: AuthUser | null, seed?: (db: Awaited<ReturnType<typeof setup>>["db"]) => Promise<unknown>) => {
|
||||
const { ctx, db } = await setup(u);
|
||||
await seed?.(db);
|
||||
const s = (await createApp(ctx, []).fetch(new Request(url))).status;
|
||||
await db.close();
|
||||
return s;
|
||||
};
|
||||
expect(await run(null)).toBe(404);
|
||||
expect(await run(user())).toBe(404);
|
||||
expect(await run(user({ roles: ["owner"] }))).toBe(200);
|
||||
expect(await run(user(), (db) => db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'u1')`)).toBe(200);
|
||||
expect(await run(user(), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'user', 'u1')`)).toBe(200);
|
||||
expect(await run(user({ groups: ["g"] }), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'group', 'g')`)).toBe(200);
|
||||
expect(await run(user({ groups: ["h"] }), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'group', 'g')`)).toBe(404);
|
||||
});
|
||||
|
||||
test("duplicate method+path across features throws", async () => {
|
||||
const { ctx, db } = await setup();
|
||||
expect(() => createApp(ctx, [feat("a"), feat("b")])).toThrow(/Duplicate route GET \/api\/x/);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("feature routes receive RequestContext with the user", async () => {
|
||||
const u = user();
|
||||
const { ctx, db } = await setup(u);
|
||||
const f: Feature = { name: "a", routes: { "/api/who/:id": { GET: (_r, rc) => Response.json({ u: rc.user?.id, id: rc.params.id }) } } };
|
||||
const app = createApp(ctx, [f]);
|
||||
expect(await (await app.fetch(new Request("http://x/api/who/42"))).json()).toEqual({ u: "u1", id: "42" });
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("canView matrix", async () => {
|
||||
const { ctx, db } = await setup();
|
||||
const pub = ctx.courses.get("sample")!, priv = ctx.courses.get("secret")!;
|
||||
expect(canView(null, pub, [])).toBe(true);
|
||||
expect(canView(null, priv, [])).toBe(false);
|
||||
await db.close();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test, spyOn } from "bun:test";
|
||||
import { loadConfig } from "../src";
|
||||
|
||||
test("defaults", () => {
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||
const c = loadConfig({});
|
||||
expect(c.port).toBe(3000);
|
||||
expect(c.databaseUrl).toBe("sqlite://./data/stcourses.db");
|
||||
expect(c.dataDir).toBe("./data");
|
||||
expect(c.courseDirs).toEqual(["./overlays", "./courses"]);
|
||||
expect(c.publicUrl).toBe("http://localhost:3000");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test("colon splitting and overrides", () => {
|
||||
const c = loadConfig({ STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test" });
|
||||
expect(c.port).toBe(8080);
|
||||
expect(c.courseDirs).toEqual(["/a b", "/c"]);
|
||||
expect(c.secret).toBe("s");
|
||||
expect(c.publicUrl).toBe("https://x.test");
|
||||
});
|
||||
|
||||
test("production without a secret throws", () => {
|
||||
expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/STC_SECRET/);
|
||||
expect(loadConfig({ NODE_ENV: "production", STC_SECRET: "x" }).secret).toBe("x");
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { jobsRepo } from "@stc/db";
|
||||
import { createJobQueue } from "../src";
|
||||
|
||||
const until = async (f: () => Promise<boolean>) => {
|
||||
for (let i = 0; i < 200; i++) { if (await f()) return; await Bun.sleep(10); }
|
||||
throw new Error("timeout");
|
||||
};
|
||||
|
||||
forEachDb("job runs to done with result and persisted progress", async (db) => {
|
||||
const q = createJobQueue(db, {
|
||||
ok: async (p, jc) => { await jc.reportProgress(0.5, "half"); return { echoed: p }; },
|
||||
}, { pollMs: 10 });
|
||||
q.start();
|
||||
try {
|
||||
const id = await q.enqueue("ok", { a: 1 });
|
||||
await until(async () => (await q.get(id))?.status === "done");
|
||||
const j = (await q.get(id))!;
|
||||
expect(j.result).toEqual({ echoed: { a: 1 } });
|
||||
expect(j.message).toBe("half");
|
||||
expect(j.progress).toBe(1);
|
||||
} finally { await q.stop(); }
|
||||
});
|
||||
|
||||
forEachDb("throwing handler gives failed with message", async (db) => {
|
||||
const q = createJobQueue(db, { bad: async () => { throw new Error("boom"); } }, { pollMs: 10 });
|
||||
q.start();
|
||||
try {
|
||||
const id = await q.enqueue("bad", null);
|
||||
await until(async () => (await q.get(id))?.status === "failed");
|
||||
expect((await q.get(id))!.error).toBe("boom");
|
||||
} finally { await q.stop(); }
|
||||
});
|
||||
|
||||
forEachDb("progress updates persist while running", async (db) => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const q = createJobQueue(db, { slow: async (_p, jc) => { await jc.reportProgress(0.3, "working"); await gate; return 1; } }, { pollMs: 10 });
|
||||
q.start();
|
||||
try {
|
||||
const id = await q.enqueue("slow", null);
|
||||
await until(async () => (await q.get(id))?.progress === 0.3);
|
||||
expect((await q.get(id))!.status).toBe("running");
|
||||
expect((await q.get(id))!.message).toBe("working");
|
||||
release();
|
||||
await until(async () => (await q.get(id))?.status === "done");
|
||||
} finally { release(); await q.stop(); }
|
||||
});
|
||||
|
||||
forEachDb("stale running job is requeued at start", async (db) => {
|
||||
const id = await jobsRepo(db).insert("ok", { z: 1 });
|
||||
await jobsRepo(db).claimNext(["ok"]);
|
||||
expect((await jobsRepo(db).get(id))!.status).toBe("running");
|
||||
const q = createJobQueue(db, { ok: async () => "again" }, { pollMs: 10 });
|
||||
q.start();
|
||||
try {
|
||||
await until(async () => (await q.get(id))?.status === "done");
|
||||
expect((await q.get(id))!.result).toBe("again");
|
||||
} finally { await q.stop(); }
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serveMedia } from "../src";
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-media-"));
|
||||
const file = join(dir, "clip.mp4");
|
||||
const bytes = new Uint8Array(1000).map((_, i) => i % 251);
|
||||
writeFileSync(file, bytes);
|
||||
const req = (range?: string) => new Request("http://x/m", { headers: range ? { Range: range } : {} });
|
||||
|
||||
test("full file gives 200", async () => {
|
||||
const r = await serveMedia(req(), file);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toBe("video/mp4");
|
||||
expect(r.headers.get("accept-ranges")).toBe("bytes");
|
||||
expect(r.headers.get("content-length")).toBe("1000");
|
||||
expect((await r.arrayBuffer()).byteLength).toBe(1000);
|
||||
});
|
||||
|
||||
test("bytes=0-99 gives 206 with Content-Range", async () => {
|
||||
const r = await serveMedia(req("bytes=0-99"), file);
|
||||
expect(r.status).toBe(206);
|
||||
expect(r.headers.get("content-range")).toBe("bytes 0-99/1000");
|
||||
expect(r.headers.get("content-length")).toBe("100");
|
||||
expect(new Uint8Array(await r.arrayBuffer())).toEqual(bytes.slice(0, 100));
|
||||
});
|
||||
|
||||
test("open-ended, suffix and clamped ranges", async () => {
|
||||
let r = await serveMedia(req("bytes=900-"), file);
|
||||
expect(r.status).toBe(206);
|
||||
expect(r.headers.get("content-range")).toBe("bytes 900-999/1000");
|
||||
r = await serveMedia(req("bytes=-10"), file);
|
||||
expect(r.headers.get("content-range")).toBe("bytes 990-999/1000");
|
||||
expect(new Uint8Array(await r.arrayBuffer())).toEqual(bytes.slice(990));
|
||||
r = await serveMedia(req("bytes=990-5000"), file);
|
||||
expect(r.headers.get("content-range")).toBe("bytes 990-999/1000");
|
||||
});
|
||||
|
||||
test("unsatisfiable range gives 416", async () => {
|
||||
const r = await serveMedia(req("bytes=999999999-"), file);
|
||||
expect(r.status).toBe(416);
|
||||
expect(r.headers.get("content-range")).toBe("bytes */1000");
|
||||
});
|
||||
|
||||
test("non-ASCII filename gets a valid filename*", async () => {
|
||||
const f = join(dir, "Æble notes.pdf");
|
||||
writeFileSync(f, "pdf");
|
||||
const r = await serveMedia(req(), f, { download: true });
|
||||
expect(r.headers.get("content-type")).toBe("application/pdf");
|
||||
const cd = r.headers.get("content-disposition")!;
|
||||
expect(cd).toContain("filename*=UTF-8''%C3%86ble%20notes.pdf");
|
||||
expect(cd).toContain('filename="_ble notes.pdf"');
|
||||
});
|
||||
|
||||
test("missing file is 404", async () => {
|
||||
expect((await serveMedia(req(), join(dir, "nope.mp4"))).status).toBe(404);
|
||||
});
|
||||
@@ -5,3 +5,4 @@ export { notesRepo, type Note, type NoteInput } from "./repos/notes";
|
||||
export { attemptsRepo, type Attempt } from "./repos/attempts";
|
||||
export { libraryRepo, type Collection } from "./repos/library";
|
||||
export { jobsRepo, type JobPatch } from "./repos/jobs";
|
||||
export { aclRepo, type AclEntry } from "./repos/acl";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Db } from "../connect";
|
||||
|
||||
export interface AclEntry { principal_type: "user" | "group"; principal_id: string }
|
||||
|
||||
export function aclRepo(db: Db) {
|
||||
return {
|
||||
async listForCourse(courseId: string): Promise<AclEntry[]> {
|
||||
const rows = await db.sql`SELECT principal_type, principal_id FROM course_acl WHERE course_id = ${courseId} ORDER BY principal_type, principal_id`;
|
||||
return rows.map((r: AclEntry) => ({ principal_type: r.principal_type, principal_id: r.principal_id }));
|
||||
},
|
||||
async isAuthor(courseId: string, userId: string): Promise<boolean> {
|
||||
const rows = await db.sql`SELECT 1 AS x FROM course_authors WHERE course_id = ${courseId} AND user_id = ${userId}`;
|
||||
return rows.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "./helpers";
|
||||
import { aclRepo } from "../src";
|
||||
|
||||
forEachDb("aclRepo lists ACL rows and detects authors", async (db) => {
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('c', 'user', 'u1'), ('c', 'group', 'g1'), ('d', 'user', 'u2')`;
|
||||
await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('c', 'a1')`;
|
||||
const r = aclRepo(db);
|
||||
expect(await r.listForCourse("c")).toEqual([
|
||||
{ principal_type: "group", principal_id: "g1" },
|
||||
{ principal_type: "user", principal_id: "u1" },
|
||||
]);
|
||||
expect(await r.listForCourse("none")).toEqual([]);
|
||||
expect(await r.isAuthor("c", "a1")).toBe(true);
|
||||
expect(await r.isAuthor("c", "u1")).toBe(false);
|
||||
expect(await r.isAuthor("d", "a1")).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user