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
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
293 lines
9.5 KiB
TypeScript
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;
|
|
}
|