import KoaRouter from "@koa/router"; import type { BackendDbInterface } from "../db/types/DbInterface.js"; import { convertError } from "./middleware/convertError.js"; import { jsonBody } from "./middleware/jsonBody.js"; import { createabode, createapikey, createnote, createresident, createuser, loginuser, updateabode, updateresident, updatenote, updateuser, } from "../schema/validators.js"; import { authenticate } from "./middleware/authenticate.js"; import { InvalidAbodeError, NotAuthorizedAbodeError, NotFoundAbodeError, } from "../db/types/errors.js"; import { isExportable } from "../db/types/ExportImport.js"; import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js"; import { intersectExportFilters, isExportKind } from "../db/export/filter.js"; import { computeForcedExportFilter } from "./exportScope.js"; import { hasGlobalUserVisibility, hideUserEmail, userForCaller, } from "./userVisibility.js"; function parseExportFilter(query: Record): ExportFilter { const list = (v: unknown): string[] | undefined => { if (typeof v !== "string" || !v) return undefined; return v.split(",").filter(Boolean); }; const kinds = (v: unknown): ExportKind[] | undefined => list(v)?.filter(isExportKind); const filter: ExportFilter = {}; const k = kinds(query.kinds); if (k) filter.kinds = k; const ek = kinds(query.excludeKinds); if (ek) filter.excludeKinds = ek; const abodes = list(query.abodes); if (abodes) filter.abodes = abodes; const users = list(query.users); if (users) filter.users = users; return filter; } export function apirouter(db: BackendDbInterface): KoaRouter { const router = new KoaRouter(); router.use(convertError); router.post("/auth/login", jsonBody({ validate: loginuser }), async (ctx) => { const user = await db.getUserByLogin(ctx.request.body); const token = await db.createSession(user.uid); ctx.cookies.set("abode_session", token); ctx.body = user; }); router.post("/auth/logout", authenticate(db), async (ctx) => { if (ctx.session!.source !== "session") throw new InvalidAbodeError(); const token = ctx.cookies.get("abode_session"); if (token) await db.deleteSession(token as `as_${string}`); ctx.cookies.set("abode_session", "", { expires: new Date("1970-01-01") }); ctx.status = 204; }); router.get("/auth/self", authenticate(db), async (ctx) => { ctx.body = ctx.user!; }); router.post("/auth/clear-sessions", authenticate(db), async (ctx) => { await db.deleteSessionsByUser(ctx.user!.uid); ctx.status = 204; }); router.get("/export", authenticate(db), async (ctx) => { // Everything that can throw a domain error runs *before* any byte is // written, so `convertError` still applies. Once `ctx.body` is a stream, // a mid-stream failure surfaces as a trailing `error` NDJSON line instead. const forced = await computeForcedExportFilter(db, { user: ctx.user!, session: ctx.session!, }); const effective = intersectExportFilters( parseExportFilter(ctx.query), forced, ); if (!isExportable(db)) { ctx.status = 501; ctx.body = { ok: false, error: "export_unsupported" }; return; } const ac = new AbortController(); ctx.res.on("close", () => { if (!ctx.res.writableEnded) ac.abort(); }); ctx.type = "application/x-ndjson"; ctx.body = db.export({ filter: effective, signal: ac.signal }); }); router.use("/users", authenticate(db)); router.get("/users", async (ctx) => { const users = await db.listUsers(); ctx.body = users.map((user) => userForCaller(user, { user: ctx.user!, session: ctx.session! }), ); }); router.post("/users", jsonBody({ validate: createuser }), async (ctx) => { ctx.body = await db.createUser(ctx.request.body); }); router.get("/users/by-email", async (ctx) => { if (typeof ctx.query.email !== "string") throw new InvalidAbodeError(); if ( !hasGlobalUserVisibility({ user: ctx.user!, session: ctx.session!, }) ) { throw new NotAuthorizedAbodeError(); } ctx.body = await db.getUserByEmail(ctx.query.email); }); router.get("/users/:uid", async (ctx) => { const user = await db.getUserById(ctx.params.uid); ctx.body = userForCaller(user, { user: ctx.user!, session: ctx.session!, }); }); router.patch( "/users/:uid", jsonBody({ validate: updateuser, includeParams: ["uid"] }), async (ctx) => { ctx.body = await db.updateUser(ctx.request.body); }, ); router.delete("/users/:uid", async (ctx) => { await db.deleteUserById(ctx.params.uid); ctx.status = 204; }); router.get("/users/:uid/residents", async (ctx) => { ctx.body = await db.listResidentsByUserId(ctx.params.uid); }); router.get("/users/:uid/abodes", async (ctx) => { ctx.body = await db.listAbodesByUserId(ctx.params.uid); }); router.get("/users/:uid/apikeys", async (ctx) => { ctx.body = await db.listApikeysByUser(ctx.params.uid); }); router.post( "/users/:uid/apikeys", jsonBody({ validate: createapikey, includeParams: ["uid"] }), async (ctx) => { const [apikey, token] = await db.createApikey(ctx.request.body); ctx.body = { apikey, token }; }, ); router.get("/users/:uid/apikeys/:kid", async (ctx) => { const apikey = await db.getApikeyById(ctx.params.kid); if (apikey.uid !== ctx.params.uid) throw new NotFoundAbodeError(); ctx.body = apikey; }); router.delete("/users/:uid/apikeys/:kid", async (ctx) => { const apikey = await db.getApikeyById(ctx.params.kid); if (apikey.uid !== ctx.params.uid) throw new NotFoundAbodeError(); await db.deleteApikeyById(ctx.params.kid); ctx.status = 204; }); router.post("/users/:uid/auth/clear-sessions", async (ctx) => { await db.deleteSessionsByUser(ctx.params.uid); ctx.status = 204; }); router.get("/users/:uid/notes", async (ctx) => { ctx.body = await db.listNotesByUserId(ctx.params.uid); }); router.use("/abodes", authenticate(db)); router.get("/abodes", async (ctx) => { ctx.body = await db.listAbodes(); }); router.post("/abodes", jsonBody({ validate: createabode }), async (ctx) => { ctx.body = await db.createAbode(ctx.request.body, { uid: ctx.user!.uid }); }); router.get("/abodes/:aid", async (ctx) => { ctx.body = await db.getAbodeById(ctx.params.aid); }); router.patch( "/abodes/:aid", jsonBody({ validate: updateabode, includeParams: ["aid"] }), async (ctx) => { ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid }); }, ); router.delete("/abodes/:aid", async (ctx) => { await db.deleteAbodeById(ctx.params.aid); ctx.status = 204; }); router.get("/abodes/:aid/residents", async (ctx) => { ctx.body = await db.listResidentsByAbodeId(ctx.params.aid); }); router.get("/abodes/:aid/users", async (ctx) => { const users = await db.listUsersByAbodeId(ctx.params.aid); const globalVisibility = hasGlobalUserVisibility({ user: ctx.user!, session: ctx.session!, }); const residents = globalVisibility ? [] : await db.listResidentsByAbodeId(ctx.params.aid); const abodeAdmin = residents.some( (resident) => resident.uid === ctx.user!.uid && resident.flags.admin === true, ); ctx.body = globalVisibility || abodeAdmin ? users : users.map((user) => user.uid === ctx.user!.uid ? user : hideUserEmail(user), ); }); router.get("/abodes/:aid/notes", async (ctx) => { ctx.body = await db.listNotesByAbodeId(ctx.params.aid); }); router.post( "/abodes/:aid/notes", jsonBody({ validate: createnote, includeParams: ["aid"] }), async (ctx) => { ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid }); }, ); router.use("/residents", authenticate(db)); router.get("/residents", async (ctx) => { ctx.body = await db.listResidents(); }); router.post( "/residents", jsonBody({ validate: createresident }), async (ctx) => { ctx.body = await db.createResident(ctx.request.body, { uid: ctx.user!.uid, }); }, ); router.get("/residents/:uid/:aid", async (ctx) => { ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid); }); router.patch( "/residents/:uid/:aid", jsonBody({ validate: updateresident, includeParams: ["uid", "aid"] }), async (ctx) => { ctx.body = await db.updateResident(ctx.request.body, { uid: ctx.user!.uid, }); }, ); router.delete("/residents/:uid/:aid", async (ctx) => { await db.deleteResidentById(ctx.params.uid, ctx.params.aid); ctx.status = 204; }); router.use("/apikeys", authenticate(db)); router.get("/apikeys/:kid", async (ctx) => { ctx.body = await db.getApikeyById(ctx.params.kid); }); router.delete("/apikeys/:kid", async (ctx) => { await db.deleteApikeyById(ctx.params.kid); ctx.status = 204; }); router.use("/notes", authenticate(db)); router.get("/notes", async (ctx) => { ctx.body = await db.listNotes(); }); router.get("/notes/:nid", async (ctx) => { ctx.body = await db.getNoteById(ctx.params.nid); }); router.patch( "/notes/:nid", jsonBody({ validate: updatenote, includeParams: ["nid"] }), async (ctx) => { ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid }); }, ); router.delete("/notes/:nid", async (ctx) => { await db.deleteNoteById(ctx.params.nid); ctx.status = 204; }); return router; }