Files
abode/src/webapi/apirouter.ts
T
codingetandCodex a1992eb57a
CI / lint (pull_request) Successful in 32s
CI / format (pull_request) Successful in 33s
CI / install-and-build (pull_request) Successful in 1m9s
CI / typecheck-tests (pull_request) Successful in 28s
CI / typecheck-source (pull_request) Successful in 28s
CI / test (pull_request) Successful in 39s
fix: restrict user email visibility
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
2026-07-23 18:49:49 +00:00

293 lines
9.5 KiB
TypeScript

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<string, unknown>): 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;
}