diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts new file mode 100644 index 0000000..fc7554f --- /dev/null +++ b/packages/core/src/app.ts @@ -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 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); + }, + }; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..0359442 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,22 @@ +import type { AppConfig } from "@stc/contracts"; + +const DEV_SECRET = "stc-dev-secret-change-me"; + +export function loadConfig(env: Record = 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}`, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..d955c36 --- /dev/null +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/jobs.ts b/packages/core/src/jobs.ts new file mode 100644 index 0000000..21f365d --- /dev/null +++ b/packages/core/src/jobs.ts @@ -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, opts: { pollMs?: number } = {}): JobQueue & { start(): void; stop(): Promise } { + const repo = jobsRepo(db); + const pollMs = opts.pollMs ?? 500; + const kinds = Object.keys(handlers); + let running = false; + let loop: Promise | null = null; + let wake: (() => void) | null = null; + let abort = new AbortController(); + + const runOne = async (): Promise => { + 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((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; + }, + }; +} diff --git a/packages/core/src/media.ts b/packages/core/src/media.ts new file mode 100644 index 0000000..941c1ed --- /dev/null +++ b/packages/core/src/media.ts @@ -0,0 +1,57 @@ +const TYPES: Record = { + 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 { + 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 = { + "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 }); +} diff --git a/packages/core/test/app.test.ts b/packages/core/test/app.test.ts new file mode 100644 index 0000000..6a8545c --- /dev/null +++ b/packages/core/test/app.test.ts @@ -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 => ({ 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>["db"]) => Promise) => { + 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(); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts new file mode 100644 index 0000000..3305ea7 --- /dev/null +++ b/packages/core/test/config.test.ts @@ -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"); +}); diff --git a/packages/core/test/jobs.test.ts b/packages/core/test/jobs.test.ts new file mode 100644 index 0000000..d65a5ad --- /dev/null +++ b/packages/core/test/jobs.test.ts @@ -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) => { + 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((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(); } +}); diff --git a/packages/core/test/media.test.ts b/packages/core/test/media.test.ts new file mode 100644 index 0000000..10fdb74 --- /dev/null +++ b/packages/core/test/media.test.ts @@ -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); +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7dba514..ec4d4e5 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; diff --git a/packages/db/src/repos/acl.ts b/packages/db/src/repos/acl.ts new file mode 100644 index 0000000..d5ccd50 --- /dev/null +++ b/packages/db/src/repos/acl.ts @@ -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 { + 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 { + const rows = await db.sql`SELECT 1 AS x FROM course_authors WHERE course_id = ${courseId} AND user_id = ${userId}`; + return rows.length > 0; + }, + }; +} diff --git a/packages/db/test/acl.test.ts b/packages/db/test/acl.test.ts new file mode 100644 index 0000000..ad0c6fc --- /dev/null +++ b/packages/db/test/acl.test.ts @@ -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); +});