v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
200
server/src/admin-cli.js
Normal file
200
server/src/admin-cli.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env node
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN ACCOUNT CLI
|
||||
|
||||
The only way an account comes into existence. Run it on the box,
|
||||
against the live database:
|
||||
|
||||
cd /srv/ngu-api
|
||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
|
||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
|
||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
|
||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
|
||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
|
||||
|
||||
add takes --role=viewer for read-only, --name="Full Name".
|
||||
Press enter at the password prompt and it generates one and
|
||||
prints it once.
|
||||
|
||||
Changing or disabling a password also drops that person's live
|
||||
sessions, so "disable" takes effect now rather than in 30 days.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { openDatabase, migrate } from "./db.js";
|
||||
import { hashPassword, destroyAllSessionsFor } from "./auth.js";
|
||||
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
/* ── Prompt, with echo suppressed for secrets ────────────────── */
|
||||
|
||||
function ask(question, { hidden = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
rl.muted = false;
|
||||
rl._writeToOutput = function (text) {
|
||||
if (!rl.muted) rl.output.write(text);
|
||||
};
|
||||
|
||||
rl.question(question, (answer) => {
|
||||
if (hidden) rl.output.write("\n");
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
|
||||
rl.muted = hidden;
|
||||
});
|
||||
}
|
||||
|
||||
async function readPassword() {
|
||||
const first = await ask("Password (enter to generate): ", { hidden: true });
|
||||
|
||||
if (first === "") {
|
||||
const generated = randomBytes(12).toString("base64url");
|
||||
console.log(`\nGenerated password: ${generated}`);
|
||||
console.log("Copy it now — it isn't stored anywhere readable.\n");
|
||||
return generated;
|
||||
}
|
||||
|
||||
if (first.length < MIN_PASSWORD) {
|
||||
fail(`Password must be at least ${MIN_PASSWORD} characters.`);
|
||||
}
|
||||
|
||||
const second = await ask("Again: ", { hidden: true });
|
||||
if (first !== second) fail("Passwords didn't match.");
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`✗ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ── Commands ────────────────────────────────────────────────── */
|
||||
|
||||
function findUser(db, email) {
|
||||
return db
|
||||
.prepare("SELECT id, email, name, role, is_active FROM admin_users WHERE email = ?")
|
||||
.get(email);
|
||||
}
|
||||
|
||||
async function add(db, email, flags) {
|
||||
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
|
||||
|
||||
const role = flags.role ?? "admin";
|
||||
if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
|
||||
|
||||
const password = await readPassword();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO admin_users (email, name, password_hash, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(email, flags.name ?? null, hashPassword(password), role);
|
||||
|
||||
console.log(`✓ ${email} created as ${role}`);
|
||||
}
|
||||
|
||||
async function passwd(db, email) {
|
||||
const user = findUser(db, email);
|
||||
if (!user) fail(`No account for ${email}.`);
|
||||
|
||||
const password = await readPassword();
|
||||
|
||||
db.prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?").run(
|
||||
hashPassword(password),
|
||||
user.id,
|
||||
);
|
||||
destroyAllSessionsFor(db, user.id);
|
||||
|
||||
console.log(`✓ password changed for ${email}, existing sessions ended`);
|
||||
}
|
||||
|
||||
function setActive(db, email, active) {
|
||||
const user = findUser(db, email);
|
||||
if (!user) fail(`No account for ${email}.`);
|
||||
|
||||
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
|
||||
active ? 1 : 0,
|
||||
user.id,
|
||||
);
|
||||
if (!active) destroyAllSessionsFor(db, user.id);
|
||||
|
||||
console.log(`✓ ${email} ${active ? "enabled" : "disabled"}`);
|
||||
}
|
||||
|
||||
function list(db) {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT u.email, u.name, u.role, u.is_active, u.last_login_at,
|
||||
(SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions
|
||||
FROM admin_users u
|
||||
ORDER BY u.email`,
|
||||
)
|
||||
.all();
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("No accounts yet. Create one with: admin-cli.js add you@ngu.org");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
const state = r.is_active ? r.role : "disabled";
|
||||
const seen = r.last_login_at ?? "never";
|
||||
console.log(
|
||||
`${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Entry ───────────────────────────────────────────────────── */
|
||||
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
const flags = {};
|
||||
const positional = [];
|
||||
for (const arg of rest) {
|
||||
const match = /^--([^=]+)=(.*)$/.exec(arg);
|
||||
if (match) flags[match[1]] = match[2];
|
||||
else positional.push(arg);
|
||||
}
|
||||
|
||||
const email = positional[0]?.trim().toLowerCase();
|
||||
|
||||
const db = await openDatabase(process.env.DB_PATH ?? "./ngu.db");
|
||||
migrate(db); // so a fresh database gets the tables before we use them
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "add":
|
||||
if (!email) fail("Usage: admin-cli.js add email@example.com");
|
||||
await add(db, email, flags);
|
||||
break;
|
||||
case "passwd":
|
||||
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
|
||||
await passwd(db, email);
|
||||
break;
|
||||
case "disable":
|
||||
setActive(db, email, false);
|
||||
break;
|
||||
case "enable":
|
||||
setActive(db, email, true);
|
||||
break;
|
||||
case "list":
|
||||
list(db);
|
||||
break;
|
||||
default:
|
||||
console.log("Commands: add, passwd, disable, enable, list");
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
533
server/src/admin-crud.js
Normal file
533
server/src/admin-crud.js
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN CRUD ENGINE
|
||||
|
||||
Reads a descriptor from admin-schema.js and does the SQL. No
|
||||
entity names appear in this file.
|
||||
|
||||
Writes run inside tx() so a parent, its side table, and every
|
||||
child collection either all land or none do. Children are
|
||||
replaced wholesale rather than diffed: the client sends the list
|
||||
it wants to exist, the engine deletes and reinserts in array
|
||||
order, and sort_order becomes the index. That makes drag-to-
|
||||
reorder free and removes a whole class of "which row is this"
|
||||
bugs, at the cost of churning autoincrement ids — which is fine
|
||||
precisely because nothing references them.
|
||||
|
||||
A column that isn't present in the payload at all is left out of
|
||||
the statement entirely, so the table's DEFAULT applies on insert
|
||||
and the existing value survives on update. A column present but
|
||||
empty ("" or null) is an explicit clear and writes NULL. The
|
||||
difference matters: sending NULL for every unmentioned column is
|
||||
what turns a missing form field into a NOT NULL constraint
|
||||
failure instead of a default.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { tx } from "./db.js";
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(status, message, fields) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/* Not a value the caller can ever send, so it can mean "leave this
|
||||
column out of the statement" without colliding with real data. */
|
||||
const OMIT = Symbol("omit");
|
||||
|
||||
/* ── Coercion ──────────────────────────────────────────────────
|
||||
SQLite STRICT tables reject a type mismatch at the wall, but
|
||||
the error it throws is unreadable. Everything is converted and
|
||||
checked here so failures come back as named fields.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
function coerceValue(column, raw, errors, prefix = "") {
|
||||
const key = `${prefix}${column.name}`;
|
||||
|
||||
// Absent from the payload. Fall back to the descriptor default
|
||||
// if it declares one, otherwise let the column's own DEFAULT do
|
||||
// the work — which needs the column omitted, not nulled.
|
||||
if (raw === undefined) {
|
||||
if (column.default !== undefined) return column.default;
|
||||
if (column.required) errors[key] = "Required.";
|
||||
return OMIT;
|
||||
}
|
||||
|
||||
// Before the blank check: an unchecked box legitimately arrives
|
||||
// as false, "", or null, and all of those mean 0, not NULL.
|
||||
if (column.type === "bool") {
|
||||
return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0;
|
||||
}
|
||||
|
||||
if (raw === null || raw === "") {
|
||||
if (column.required) {
|
||||
errors[key] = "Required.";
|
||||
return null;
|
||||
}
|
||||
// The table refuses NULL but declares a default: clearing the
|
||||
// field means "use the default", not "write NULL".
|
||||
if (column.notNullable) return OMIT;
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (column.type) {
|
||||
case "int": {
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n)) errors[key] = "Must be a whole number.";
|
||||
return Number.isInteger(n) ? n : null;
|
||||
}
|
||||
case "real": {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) errors[key] = "Must be a number.";
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
case "enum": {
|
||||
const value = String(raw);
|
||||
if (!column.values.includes(value)) {
|
||||
errors[key] = `Must be one of: ${column.values.join(", ")}.`;
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "date": {
|
||||
const value = String(raw).trim();
|
||||
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
|
||||
return ISO_DATE.test(value) ? value : null;
|
||||
}
|
||||
default: {
|
||||
const value = String(raw).trim();
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function coerceRow(columns, data, { prefix = "" } = {}) {
|
||||
const errors = {};
|
||||
const values = {};
|
||||
for (const column of columns) {
|
||||
const value = coerceValue(column, data?.[column.name], errors, prefix);
|
||||
if (value !== OMIT) values[column.name] = value;
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
|
||||
const applies = (gate, row) => !gate || row[gate.column] === gate.value;
|
||||
|
||||
/* ── Read ────────────────────────────────────────────────────── */
|
||||
|
||||
export function listRows(db, entity, query = {}) {
|
||||
const { columns, filters, search, order } = entity.list;
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
for (const name of filters) {
|
||||
const value = query[name];
|
||||
if (value === undefined || value === "" || value === "all") continue;
|
||||
where.push(`${name} = ?`);
|
||||
params.push(value);
|
||||
}
|
||||
|
||||
if (query.q) {
|
||||
const term = `%${String(query.q).slice(0, 100)}%`;
|
||||
where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`);
|
||||
params.push(...search.map(() => term));
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 200, 500);
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT ${columns.join(", ")}
|
||||
FROM ${entity.table}
|
||||
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||
ORDER BY ${order}
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...params, limit);
|
||||
|
||||
return { rows, total: rows.length };
|
||||
}
|
||||
|
||||
export function readRow(db, entity, id) {
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||
.get(id);
|
||||
|
||||
if (!row) throw new HttpError(404, "Not found.");
|
||||
|
||||
for (const ext of entity.extensions ?? []) {
|
||||
row[ext.key] =
|
||||
db
|
||||
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
|
||||
.get(id) ?? null;
|
||||
}
|
||||
|
||||
for (const child of entity.children ?? []) {
|
||||
row[child.key] = readChildren(db, child, id);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function readChildren(db, child, ownerId) {
|
||||
const where = [`${child.owner.column} = ?`];
|
||||
const params = [ownerId];
|
||||
|
||||
if (child.owner.kindColumn) {
|
||||
where.push(`${child.owner.kindColumn} = ?`);
|
||||
params.push(child.owner.kindValue);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM ${child.table}
|
||||
WHERE ${where.join(" AND ")}
|
||||
ORDER BY ${child.order}`,
|
||||
)
|
||||
.all(...params);
|
||||
|
||||
for (const child2 of child.children ?? []) {
|
||||
for (const row of rows) {
|
||||
row[child2.key] = db
|
||||
.prepare(
|
||||
`SELECT * FROM ${child2.table}
|
||||
WHERE ${child2.owner.column} = ?
|
||||
ORDER BY ${child2.order}`,
|
||||
)
|
||||
.all(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/* ── Write ───────────────────────────────────────────────────── */
|
||||
|
||||
export function createRow(db, entity, payload) {
|
||||
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
|
||||
|
||||
if (entity.idKind === "slug" && !SLUG.test(id)) {
|
||||
throw new HttpError(422, "Validation failed", {
|
||||
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
|
||||
});
|
||||
}
|
||||
|
||||
const exists = db
|
||||
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||
.get(id);
|
||||
if (exists) {
|
||||
throw new HttpError(422, "Validation failed", {
|
||||
[entity.idColumn]: "Already taken.",
|
||||
});
|
||||
}
|
||||
|
||||
const { values, errors } = coerceRow(entity.columns, payload);
|
||||
if (Object.keys(errors).length) {
|
||||
throw new HttpError(422, "Validation failed", errors);
|
||||
}
|
||||
|
||||
const names = [entity.idColumn, ...Object.keys(values)];
|
||||
|
||||
wrapDbErrors(() =>
|
||||
tx(db, () => {
|
||||
db.prepare(
|
||||
`INSERT INTO ${entity.table} (${names.join(", ")})
|
||||
VALUES (${names.map(() => "?").join(", ")})`,
|
||||
).run(id, ...Object.values(values));
|
||||
|
||||
writeExtensions(db, entity, id, payload, values);
|
||||
writeChildren(db, entity, id, payload, values);
|
||||
}),
|
||||
);
|
||||
|
||||
return readRow(db, entity, id);
|
||||
}
|
||||
|
||||
export function updateRow(db, entity, id, payload) {
|
||||
const current = db
|
||||
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||
.get(id);
|
||||
if (!current) throw new HttpError(404, "Not found.");
|
||||
|
||||
// Optimistic concurrency. The client echoes back the updated_at
|
||||
// it loaded; anything else means someone saved in between.
|
||||
if (entity.concurrency) {
|
||||
const seen = payload?.[entity.concurrency];
|
||||
if (!seen) {
|
||||
throw new HttpError(400, `Missing ${entity.concurrency}.`);
|
||||
}
|
||||
if (seen !== current[entity.concurrency]) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"Someone else saved this while you were editing. Reload to see their version.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { values, errors } = coerceRow(entity.columns, payload);
|
||||
if (Object.keys(errors).length) {
|
||||
throw new HttpError(422, "Validation failed", errors);
|
||||
}
|
||||
|
||||
// An unsent column keeps its stored value, so the gates below
|
||||
// have to read the merged row, not just what came in.
|
||||
const merged = { ...current, ...values };
|
||||
|
||||
wrapDbErrors(() =>
|
||||
tx(db, () => {
|
||||
const sets = Object.keys(values).map((name) => `${name} = ?`);
|
||||
if (sets.length) {
|
||||
db.prepare(
|
||||
`UPDATE ${entity.table} SET ${sets.join(", ")}
|
||||
WHERE ${entity.idColumn} = ?`,
|
||||
).run(...Object.values(values), id);
|
||||
}
|
||||
|
||||
writeExtensions(db, entity, id, payload, merged);
|
||||
writeChildren(db, entity, id, payload, merged);
|
||||
}),
|
||||
);
|
||||
|
||||
return readRow(db, entity, id);
|
||||
}
|
||||
|
||||
export function deleteRow(db, entity, id) {
|
||||
const result = wrapDbErrors(() =>
|
||||
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
||||
);
|
||||
if (result.changes === 0) throw new HttpError(404, "Not found.");
|
||||
}
|
||||
|
||||
/* ── Write helpers ───────────────────────────────────────────── */
|
||||
|
||||
function writeExtensions(db, entity, id, payload, parentValues) {
|
||||
for (const ext of entity.extensions ?? []) {
|
||||
if (!applies(ext.when, parentValues)) {
|
||||
// The gate closed — the kind changed away from this side
|
||||
// table, so its row (and anything cascading off it) goes.
|
||||
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, {
|
||||
prefix: `${ext.key}.`,
|
||||
});
|
||||
if (Object.keys(errors).length) {
|
||||
throw new HttpError(422, "Validation failed", errors);
|
||||
}
|
||||
|
||||
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
|
||||
const names = [ext.idColumn, ...Object.keys(values)];
|
||||
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
||||
|
||||
// Upsert rather than delete-and-insert: deleting a regions row
|
||||
// would cascade its region_areas away underneath us. With every
|
||||
// optional column omitted there is nothing to set, so the
|
||||
// conflict clause has to degrade to DO NOTHING or the SQL is
|
||||
// syntactically invalid.
|
||||
db.prepare(
|
||||
`INSERT INTO ${ext.table} (${names.join(", ")})
|
||||
VALUES (${names.map(() => "?").join(", ")})
|
||||
ON CONFLICT(${ext.idColumn}) ${
|
||||
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
||||
}`,
|
||||
).run(id, ...Object.values(values));
|
||||
}
|
||||
}
|
||||
|
||||
function isBlankChildRow(child, raw) {
|
||||
if (!raw || typeof raw !== "object") return true;
|
||||
|
||||
for (const column of child.columns) {
|
||||
const value = raw[column.name];
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
// An unchecked box is the default state of a new row, not input.
|
||||
if (
|
||||
column.type === "bool" &&
|
||||
(value === false || value === 0 || value === "0" || value === "false")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const child2 of child.children ?? []) {
|
||||
const nested = raw[child2.key];
|
||||
if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function keyedDbErrors(prefix, fn) {
|
||||
try {
|
||||
return fn();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) throw err;
|
||||
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? ""));
|
||||
if (notNull) {
|
||||
throw new HttpError(422, "Validation failed", {
|
||||
[`${prefix}${notNull[1]}`]: "Required.",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function writeChildren(db, entity, id, payload, parentValues) {
|
||||
for (const child of entity.children ?? []) {
|
||||
if (!applies(child.when, parentValues)) {
|
||||
deleteChildren(db, child, id);
|
||||
continue;
|
||||
}
|
||||
if (payload[child.key] === undefined) continue; // not sent, not touched
|
||||
|
||||
deleteChildren(db, child, id);
|
||||
|
||||
const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : [];
|
||||
|
||||
// index is the row's place in what the client sent, so a
|
||||
// validation error still points at the row the user is looking
|
||||
// at. position counts only the rows that survive, so dropping a
|
||||
// blank in the middle doesn't leave a gap in sort_order.
|
||||
const rows = incoming
|
||||
.map((raw, index) => ({ raw, index }))
|
||||
.filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw));
|
||||
|
||||
rows.forEach(({ raw, index }, position) => {
|
||||
const { values, errors } = coerceRow(child.columns, raw, {
|
||||
prefix: `${child.key}.${index}.`,
|
||||
});
|
||||
if (Object.keys(errors).length) {
|
||||
throw new HttpError(422, "Validation failed", errors);
|
||||
}
|
||||
|
||||
const names = [child.owner.column, ...Object.keys(values)];
|
||||
const params = [id, ...Object.values(values)];
|
||||
|
||||
if (child.owner.kindColumn) {
|
||||
names.push(child.owner.kindColumn);
|
||||
params.push(child.owner.kindValue);
|
||||
}
|
||||
for (const [column, from] of Object.entries(child.owner.inherit ?? {})) {
|
||||
if (names.includes(column)) continue;
|
||||
names.push(column);
|
||||
params.push(parentValues[from] ?? id);
|
||||
}
|
||||
if (child.order === "sort_order" && !names.includes("sort_order")) {
|
||||
names.push("sort_order");
|
||||
params.push(position);
|
||||
}
|
||||
|
||||
const result = keyedDbErrors(`${child.key}.${index}.`, () =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO ${child.table} (${names.join(", ")})
|
||||
VALUES (${names.map(() => "?").join(", ")})`,
|
||||
)
|
||||
.run(...params),
|
||||
);
|
||||
|
||||
for (const child2 of child.children ?? []) {
|
||||
const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : [];
|
||||
const nested = incomingNested
|
||||
.map((rawItem, i) => ({ rawItem, i }))
|
||||
.filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem));
|
||||
|
||||
nested.forEach(({ rawItem, i }, nestedPosition) => {
|
||||
const item = coerceRow(child2.columns, rawItem, {
|
||||
prefix: `${child.key}.${index}.${child2.key}.${i}.`,
|
||||
});
|
||||
if (Object.keys(item.errors).length) {
|
||||
throw new HttpError(422, "Validation failed", item.errors);
|
||||
}
|
||||
|
||||
const itemNames = [child2.owner.column, ...Object.keys(item.values)];
|
||||
const itemParams = [result.lastInsertRowid, ...Object.values(item.values)];
|
||||
if (!itemNames.includes("sort_order")) {
|
||||
itemNames.push("sort_order");
|
||||
itemParams.push(nestedPosition);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO ${child2.table} (${itemNames.join(", ")})
|
||||
VALUES (${itemNames.map(() => "?").join(", ")})`,
|
||||
).run(...itemParams);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChildren(db, child, ownerId) {
|
||||
if (child.owner.kindColumn) {
|
||||
db.prepare(
|
||||
`DELETE FROM ${child.table}
|
||||
WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`,
|
||||
).run(ownerId, child.owner.kindValue);
|
||||
} else {
|
||||
db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run(
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* SQLite's constraint messages are accurate and unreadable. Turn
|
||||
the ones that users actually cause into something actionable,
|
||||
and name the column wherever the message carries it — a missing
|
||||
field is the client's problem to fix, not a 500.
|
||||
|
||||
The descriptor and the schema agreeing (see admin-schema-sync.js)
|
||||
should stop most of these arriving. This is the backstop for the
|
||||
cases it can't see: partial indexes, triggers, CHECK constraints. */
|
||||
function wrapDbErrors(fn) {
|
||||
try {
|
||||
return fn();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) throw err;
|
||||
const message = String(err.message ?? "");
|
||||
|
||||
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message);
|
||||
if (notNull) {
|
||||
throw new HttpError(422, "Validation failed", {
|
||||
[notNull[1]]: "Required.",
|
||||
});
|
||||
}
|
||||
|
||||
const unique = /UNIQUE constraint failed: (.+)/.exec(message);
|
||||
if (unique) {
|
||||
const columns = unique[1]
|
||||
.split(",")
|
||||
.map((part) => part.trim().split(".")[1])
|
||||
.filter(Boolean);
|
||||
if (columns.length === 1) {
|
||||
throw new HttpError(422, "Validation failed", {
|
||||
[columns[0]]: "Already taken.",
|
||||
});
|
||||
}
|
||||
throw new HttpError(422, "That combination already exists.");
|
||||
}
|
||||
|
||||
if (message.includes("FOREIGN KEY")) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"Something references a row that doesn't exist, or is still referenced elsewhere.",
|
||||
);
|
||||
}
|
||||
|
||||
const check = /CHECK constraint failed: (\w+)/.exec(message);
|
||||
if (check) {
|
||||
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
136
server/src/admin-schema-sync.js
Normal file
136
server/src/admin-schema-sync.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
DESCRIPTOR / SCHEMA RECONCILIATION
|
||||
|
||||
The CRUD engine trusts admin-schema.js about which fields are
|
||||
required. The database is the one that actually enforces it. When
|
||||
those two disagree the gap shows up as a 500 on somebody's save,
|
||||
which is how organizations.country was found.
|
||||
|
||||
Run this once at boot. For every table a descriptor touches it
|
||||
compares the declared columns against PRAGMA table_info and:
|
||||
|
||||
- marks a descriptor column required when the table says
|
||||
NOT NULL with no DEFAULT, so validation catches it as a named
|
||||
field instead of SQLite catching it as a 500;
|
||||
- reports a descriptor column the table doesn't have;
|
||||
- reports a NOT NULL, no-DEFAULT column that no descriptor
|
||||
column and no engine-supplied column covers — nothing can
|
||||
ever set it, so every insert through the admin will fail.
|
||||
|
||||
The first is a silent repair. The other two are deployment bugs,
|
||||
so with { strict: true } they stop the service starting rather
|
||||
than waiting to surface one row at a time.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
function tableMeta(db, table) {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!rows.length) return null;
|
||||
|
||||
const meta = new Map();
|
||||
for (const row of rows) {
|
||||
meta.set(row.name, {
|
||||
// A NOT NULL column with no default and no engine to fill it
|
||||
// has to arrive in the payload or the insert dies.
|
||||
mustSupply: row.notnull === 1 && row.dflt_value === null && row.pk === 0,
|
||||
notNullable: row.notnull === 1 && row.pk === 0,
|
||||
});
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* Every table the descriptor writes to, with the columns it
|
||||
declares and the ones the engine fills in on its own. */
|
||||
function collectGroups(entity) {
|
||||
const groups = [
|
||||
{
|
||||
table: entity.table,
|
||||
columns: entity.columns ?? [],
|
||||
engineSupplied: [entity.idColumn],
|
||||
},
|
||||
];
|
||||
|
||||
for (const ext of entity.extensions ?? []) {
|
||||
groups.push({
|
||||
table: ext.table,
|
||||
columns: ext.columns ?? [],
|
||||
engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])],
|
||||
});
|
||||
}
|
||||
|
||||
const walk = (child) => {
|
||||
groups.push({
|
||||
table: child.table,
|
||||
columns: child.columns ?? [],
|
||||
engineSupplied: [
|
||||
child.owner.column,
|
||||
child.owner.kindColumn,
|
||||
"sort_order",
|
||||
...Object.keys(child.owner.inherit ?? {}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
for (const nested of child.children ?? []) walk(nested);
|
||||
};
|
||||
|
||||
for (const child of entity.children ?? []) walk(child);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function syncDescriptorsWithSchema(db, entities, { strict = true } = {}) {
|
||||
const list = Array.isArray(entities) ? entities.map((e) => [e.name ?? e.table, e]) : Object.entries(entities);
|
||||
|
||||
const inferred = [];
|
||||
const problems = [];
|
||||
|
||||
for (const [name, entity] of list) {
|
||||
for (const group of collectGroups(entity)) {
|
||||
const meta = tableMeta(db, group.table);
|
||||
|
||||
if (!meta) {
|
||||
problems.push(`${name}: table "${group.table}" does not exist.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const declared = new Set(group.columns.map((column) => column.name));
|
||||
|
||||
for (const column of group.columns) {
|
||||
const info = meta.get(column.name);
|
||||
if (!info) {
|
||||
problems.push(
|
||||
`${name}: ${group.table}.${column.name} is in the descriptor but not in the table.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (info.mustSupply && !column.required) {
|
||||
column.required = true;
|
||||
inferred.push(`${name}: ${group.table}.${column.name}`);
|
||||
}
|
||||
if (info.notNullable) column.notNullable = true;
|
||||
}
|
||||
|
||||
for (const [columnName, info] of meta) {
|
||||
if (!info.mustSupply) continue;
|
||||
if (declared.has(columnName)) continue;
|
||||
if (group.engineSupplied.includes(columnName)) continue;
|
||||
problems.push(
|
||||
`${name}: ${group.table}.${columnName} is NOT NULL with no default, ` +
|
||||
`but no descriptor column covers it — every insert will fail.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inferred.length) {
|
||||
console.warn(
|
||||
`[admin-schema] marked required from the schema (add required: true to the descriptor):\n ${inferred.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
const report = `[admin-schema] descriptor does not match the database:\n ${problems.join("\n ")}`;
|
||||
if (strict) throw new Error(report);
|
||||
console.error(report);
|
||||
}
|
||||
|
||||
return { inferred, problems };
|
||||
}
|
||||
518
server/src/admin-schema.js
Normal file
518
server/src/admin-schema.js
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN ENTITY DESCRIPTORS
|
||||
|
||||
One object per editable entity. Everything the CRUD handlers do
|
||||
— validation, SQL, nesting — is read from here, so adding a
|
||||
table later is a descriptor rather than another set of
|
||||
hand-written statements to keep in step with the schema.
|
||||
|
||||
Anatomy of a descriptor:
|
||||
|
||||
columns writable columns of the parent row
|
||||
extensions 1:1 side tables, optionally gated on a column
|
||||
value (organizations.kind decides whether a
|
||||
regions or chapters row should exist)
|
||||
children ordered collections, replaced wholesale on save
|
||||
|
||||
Replacing children wholesale is only safe because nothing has a
|
||||
foreign key INTO these tables. That is the dividing line, and
|
||||
it is why teams and awards are entities of their own rather
|
||||
than repeaters on the organization form: affiliations.team_id
|
||||
and person_awards.award_id point at them, so a delete-and-
|
||||
reinsert save would abort the moment either had a single
|
||||
dependent row.
|
||||
|
||||
Parent ids are immutable. Polymorphic children reference their
|
||||
owner by free-text owner_id, so renaming a slug in place would
|
||||
silently orphan every link and content block attached to it.
|
||||
|
||||
affiliations is edited from both ends — a person's roles, and a
|
||||
team's members. Each side deletes and reinserts only its own
|
||||
slice (WHERE person_id = ?, WHERE team_id = ?) and declares
|
||||
every column of the row, so a save from one side round-trips
|
||||
what the other side owns rather than blanking it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Column helpers ──────────────────────────────────────────── */
|
||||
|
||||
const text = (name, opts = {}) => ({ name, type: "text", ...opts });
|
||||
const int = (name, opts = {}) => ({ name, type: "int", ...opts });
|
||||
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
|
||||
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
|
||||
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
|
||||
const enumeration = (name, values, opts = {}) => ({
|
||||
name,
|
||||
type: "enum",
|
||||
values,
|
||||
...opts,
|
||||
});
|
||||
|
||||
/* Place columns shared by organizations and events, in schema order. */
|
||||
const placeColumns = [
|
||||
text("venue"),
|
||||
text("address"),
|
||||
text("locality"),
|
||||
text("state_code"),
|
||||
text("country"),
|
||||
text("location_label"),
|
||||
real("latitude"),
|
||||
real("longitude"),
|
||||
bool("is_online"),
|
||||
];
|
||||
|
||||
/* The affiliation's own fields, minus whichever end owns the row.
|
||||
Both editors write the same shape so neither loses the other's
|
||||
values on save. */
|
||||
const affiliationRole = [
|
||||
text("title"),
|
||||
enumeration("role", ["lead", "board", "staff", "volunteer", "member"], {
|
||||
required: true,
|
||||
}),
|
||||
bool("is_owner"),
|
||||
date("started_on"),
|
||||
date("ended_on"),
|
||||
bool("is_public"),
|
||||
];
|
||||
|
||||
/* The two polymorphic collections, parameterised by owner_kind. */
|
||||
const linksChild = (ownerKind) => ({
|
||||
key: "links",
|
||||
table: "links",
|
||||
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
|
||||
order: "sort_order",
|
||||
columns: [
|
||||
enumeration("kind", ["action", "social", "website", "email"], {
|
||||
required: true,
|
||||
}),
|
||||
text("platform"),
|
||||
text("label", { required: true }),
|
||||
text("url", { required: true }),
|
||||
bool("is_primary"),
|
||||
],
|
||||
});
|
||||
|
||||
const blocksChild = (ownerKind) => ({
|
||||
key: "content_blocks",
|
||||
table: "content_blocks",
|
||||
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
|
||||
order: "sort_order",
|
||||
columns: [
|
||||
enumeration("slot", ["card", "body"], { required: true }),
|
||||
enumeration(
|
||||
"type",
|
||||
[
|
||||
"heading",
|
||||
"subheading",
|
||||
"paragraph",
|
||||
"list",
|
||||
"links",
|
||||
"quote",
|
||||
"image",
|
||||
"divider",
|
||||
],
|
||||
{ required: true },
|
||||
),
|
||||
text("text"),
|
||||
text("media"),
|
||||
text("href"),
|
||||
],
|
||||
children: [
|
||||
{
|
||||
key: "items",
|
||||
table: "content_block_items",
|
||||
owner: { column: "block_id" },
|
||||
order: "sort_order",
|
||||
columns: [
|
||||
text("text", { required: true }),
|
||||
text("detail"),
|
||||
text("url"),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/* ── Organizations ───────────────────────────────────────────── */
|
||||
|
||||
const organizations = {
|
||||
key: "organizations",
|
||||
table: "organizations",
|
||||
idColumn: "id",
|
||||
idKind: "slug",
|
||||
concurrency: "updated_at",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
"id",
|
||||
"kind",
|
||||
"name",
|
||||
"short_name",
|
||||
"locality",
|
||||
"state_code",
|
||||
"is_published",
|
||||
"sort_order",
|
||||
"updated_at",
|
||||
],
|
||||
filters: ["kind", "is_published"],
|
||||
search: ["name", "id", "locality"],
|
||||
order: "kind, sort_order, name",
|
||||
},
|
||||
|
||||
columns: [
|
||||
enumeration("kind", ["national", "region", "chapter", "partner"], {
|
||||
required: true,
|
||||
}),
|
||||
text("name", { required: true }),
|
||||
text("short_name"),
|
||||
text("tagline"),
|
||||
text("color"),
|
||||
text("logo"),
|
||||
...placeColumns,
|
||||
bool("is_published"),
|
||||
int("sort_order"),
|
||||
],
|
||||
|
||||
extensions: [
|
||||
{
|
||||
key: "region",
|
||||
table: "regions",
|
||||
idColumn: "id",
|
||||
when: { column: "kind", value: "region" },
|
||||
columns: [
|
||||
enumeration("scope", ["domestic", "international", "virtual"], {
|
||||
required: true,
|
||||
}),
|
||||
text("map_note"),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "chapter",
|
||||
table: "chapters",
|
||||
idColumn: "id",
|
||||
when: { column: "kind", value: "chapter" },
|
||||
columns: [text("region_id"), text("meets"), text("started")],
|
||||
},
|
||||
],
|
||||
|
||||
children: [
|
||||
linksChild("organization"),
|
||||
blocksChild("organization"),
|
||||
{
|
||||
key: "region_areas",
|
||||
table: "region_areas",
|
||||
owner: { column: "region_id" },
|
||||
when: { column: "kind", value: "region" },
|
||||
order: "area_code",
|
||||
columns: [
|
||||
text("area_code", { required: true }),
|
||||
real("share"),
|
||||
enumeration("edge", ["top", "bottom"]),
|
||||
text("note"),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Events ──────────────────────────────────────────────────── */
|
||||
|
||||
const events = {
|
||||
key: "events",
|
||||
table: "events",
|
||||
idColumn: "id",
|
||||
idKind: "slug",
|
||||
concurrency: "updated_at",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
"id",
|
||||
"title",
|
||||
"section_id",
|
||||
"host_org_id",
|
||||
"date_label",
|
||||
"starts_on",
|
||||
"status",
|
||||
"is_published",
|
||||
"sort_order",
|
||||
"updated_at",
|
||||
],
|
||||
filters: ["section_id", "status", "is_published", "host_org_id"],
|
||||
search: ["title", "id", "theme"],
|
||||
order: "sort_order, starts_on DESC, title",
|
||||
},
|
||||
|
||||
columns: [
|
||||
text("section_id", { required: true }),
|
||||
text("host_org_id"),
|
||||
text("title", { required: true }),
|
||||
text("theme"),
|
||||
text("tagline"),
|
||||
date("starts_on"),
|
||||
date("ends_on"),
|
||||
text("date_label"),
|
||||
enumeration("status", ["upcoming", "past", "cancelled"]),
|
||||
...placeColumns,
|
||||
text("org_logo"),
|
||||
text("event_logo"),
|
||||
text("color"),
|
||||
text("gradient"),
|
||||
bool("is_published"),
|
||||
int("sort_order"),
|
||||
],
|
||||
|
||||
children: [
|
||||
linksChild("event"),
|
||||
blocksChild("event"),
|
||||
{
|
||||
key: "event_people",
|
||||
table: "event_people",
|
||||
owner: { column: "event_id" },
|
||||
order: "sort_order",
|
||||
columns: [
|
||||
text("person_id", { required: true }),
|
||||
enumeration(
|
||||
"role",
|
||||
[
|
||||
"speaker",
|
||||
"leader",
|
||||
"facilitator",
|
||||
"host",
|
||||
"musician",
|
||||
"volunteer",
|
||||
"attendee",
|
||||
],
|
||||
{ required: true },
|
||||
),
|
||||
text("title"),
|
||||
bool("is_public"),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ── People ──────────────────────────────────────────────────── */
|
||||
|
||||
const people = {
|
||||
key: "people",
|
||||
table: "people",
|
||||
idColumn: "id",
|
||||
idKind: "slug",
|
||||
concurrency: "updated_at",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
"id",
|
||||
"display_name",
|
||||
"sort_name",
|
||||
"tagline",
|
||||
"locality",
|
||||
"is_published",
|
||||
"sort_order",
|
||||
"updated_at",
|
||||
],
|
||||
filters: ["is_published"],
|
||||
search: ["display_name", "sort_name", "id"],
|
||||
order: "sort_order, sort_name, display_name",
|
||||
},
|
||||
|
||||
columns: [
|
||||
text("display_name", { required: true }),
|
||||
text("sort_name"),
|
||||
text("pronouns"),
|
||||
text("tagline"),
|
||||
text("photo"),
|
||||
text("bio"),
|
||||
text("primary_org_id"),
|
||||
text("public_email"),
|
||||
text("public_phone"),
|
||||
text("locality"),
|
||||
text("state_code"),
|
||||
text("country"),
|
||||
text("location_label"),
|
||||
bool("is_published"),
|
||||
int("sort_order"),
|
||||
],
|
||||
|
||||
extensions: [
|
||||
{
|
||||
key: "private",
|
||||
table: "person_private",
|
||||
idColumn: "person_id",
|
||||
touch: true, // has its own updated_at with no trigger behind it
|
||||
columns: [
|
||||
date("birth_date"),
|
||||
text("private_email"),
|
||||
text("private_phone"),
|
||||
text("address"),
|
||||
text("notes"),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
children: [
|
||||
linksChild("person"),
|
||||
blocksChild("person"),
|
||||
{
|
||||
key: "affiliations",
|
||||
table: "affiliations",
|
||||
owner: { column: "person_id" },
|
||||
|
||||
// sort_order is where this person sits on that team, so it
|
||||
// belongs to the team's editor. reindex: false stops this
|
||||
// form renumbering by row position; declaring the column
|
||||
// keeps the team's value intact across a save here.
|
||||
reindex: false,
|
||||
order: "org_id, team_id, sort_order",
|
||||
|
||||
columns: [
|
||||
text("org_id", { required: true }),
|
||||
text("team_id"),
|
||||
...affiliationRole,
|
||||
int("sort_order"),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "person_awards",
|
||||
table: "person_awards",
|
||||
owner: { column: "person_id" },
|
||||
order: "awarded_on",
|
||||
columns: [
|
||||
text("award_id", { required: true }),
|
||||
text("event_id"),
|
||||
date("awarded_on"),
|
||||
text("citation"),
|
||||
bool("is_public"),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Teams ───────────────────────────────────────────────────── */
|
||||
|
||||
// A team belongs to exactly one organization, and affiliations
|
||||
// point at the pair (team_id, org_id) rather than the team alone.
|
||||
// Two consequences the form has to live with:
|
||||
//
|
||||
// · org_id cannot be changed once anyone is filed under the
|
||||
// team. The composite foreign key has no ON UPDATE CASCADE, so
|
||||
// SQLite aborts the UPDATE. That surfaces as a constraint
|
||||
// error, which is the correct answer — reassign the members
|
||||
// first.
|
||||
//
|
||||
// · deleting a team with members fails the same way, rather than
|
||||
// quietly detaching them. Emptying the members list first is
|
||||
// now something the form can do.
|
||||
//
|
||||
// No concurrency column: teams have no updated_at. Adding one
|
||||
// means rebuilding a STRICT table for a row that one person edits
|
||||
// at a time, which is not a trade worth making yet.
|
||||
const teams = {
|
||||
key: "teams",
|
||||
table: "teams",
|
||||
idColumn: "id",
|
||||
idKind: "slug",
|
||||
|
||||
list: {
|
||||
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
|
||||
filters: ["org_id", "is_published"],
|
||||
search: ["name", "id", "tagline"],
|
||||
order: "org_id, sort_order, name",
|
||||
},
|
||||
|
||||
columns: [
|
||||
text("org_id", { required: true }),
|
||||
text("name", { required: true }),
|
||||
text("tagline"),
|
||||
text("color"),
|
||||
text("logo"),
|
||||
bool("is_published"),
|
||||
int("sort_order"),
|
||||
],
|
||||
|
||||
children: [
|
||||
// 'team' is already a valid owner_kind in both polymorphic
|
||||
// tables, and teams_cleanup drops the rows on delete, so a team
|
||||
// page comes free.
|
||||
linksChild("team"),
|
||||
blocksChild("team"),
|
||||
{
|
||||
key: "members",
|
||||
table: "affiliations",
|
||||
|
||||
// org_id is inherited from the team rather than asked for:
|
||||
// the composite foreign key (team_id, org_id) means a member
|
||||
// of this team can only belong to this team's organization,
|
||||
// so a second dropdown could only ever be wrong.
|
||||
owner: { column: "team_id", inherit: { org_id: "org_id" } },
|
||||
|
||||
// Row position is the order they appear on the public site.
|
||||
// This is the only editor that writes it.
|
||||
order: "sort_order",
|
||||
|
||||
columns: [text("person_id", { required: true }), ...affiliationRole],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Awards ──────────────────────────────────────────────────── */
|
||||
|
||||
// org_id is who gives the award, added in 004. Nullable, because
|
||||
// an award can predate any decision about which organization owns
|
||||
// it, and because person_awards rows must survive the awarding
|
||||
// org being deleted.
|
||||
//
|
||||
// No children: 'award' is not in the owner_kind CHECK on
|
||||
// content_blocks or links. If awards ever need a page of their
|
||||
// own, that CHECK is a table rebuild, so decide before adding one
|
||||
// rather than after.
|
||||
const awards = {
|
||||
key: "awards",
|
||||
table: "awards",
|
||||
idColumn: "id",
|
||||
idKind: "slug",
|
||||
|
||||
list: {
|
||||
columns: ["id", "org_id", "name", "description", "sort_order"],
|
||||
filters: ["org_id"],
|
||||
search: ["name", "id", "description"],
|
||||
order: "org_id, sort_order, name",
|
||||
},
|
||||
|
||||
columns: [
|
||||
text("org_id"),
|
||||
text("name", { required: true }),
|
||||
text("description"),
|
||||
text("logo"),
|
||||
int("sort_order"),
|
||||
],
|
||||
};
|
||||
|
||||
export const ENTITIES = { organizations, events, people, teams, awards };
|
||||
|
||||
/* ── Options for the form's select inputs ────────────────────── */
|
||||
|
||||
export const OPTION_QUERIES = {
|
||||
organizations:
|
||||
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
|
||||
regions:
|
||||
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
|
||||
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
|
||||
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
|
||||
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
|
||||
|
||||
// org_id rides along so the affiliation row can filter the list
|
||||
// down to teams of the organization it already names.
|
||||
teams:
|
||||
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
|
||||
|
||||
// The awarding organization is folded into the label instead,
|
||||
// because a person can receive an award from any organization —
|
||||
// there is nothing to filter on, only something to disambiguate
|
||||
// when two orgs name an award the same thing.
|
||||
awards: `
|
||||
SELECT a.id,
|
||||
CASE WHEN o.name IS NULL THEN a.name
|
||||
ELSE a.name || ' — ' || o.name END AS label,
|
||||
a.org_id
|
||||
FROM awards a
|
||||
LEFT JOIN organizations o ON o.id = a.org_id
|
||||
ORDER BY a.sort_order, a.name`,
|
||||
};
|
||||
226
server/src/auth.js
Normal file
226
server/src/auth.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
AUTH
|
||||
|
||||
Everything that decides who someone is. Routes decide what they
|
||||
may do.
|
||||
|
||||
Password storage is scrypt from node:crypto — no native module,
|
||||
nothing to compile, nothing for pnpm to get wrong. The stored
|
||||
string carries its own parameters, so raising the cost later
|
||||
doesn't invalidate existing hashes.
|
||||
|
||||
Sessions are opaque random tokens. The browser holds the token
|
||||
in an HttpOnly cookie; the database holds only its SHA-256. A
|
||||
leaked backup therefore contains no usable session, and signing
|
||||
someone out is a DELETE rather than a wait for expiry.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import {
|
||||
createHash,
|
||||
randomBytes,
|
||||
scryptSync,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
|
||||
export const COOKIE_NAME = "ngu_session";
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
// Re-issue the expiry when a session is used with less than this
|
||||
// left, so an active person is never signed out mid-task.
|
||||
const REFRESH_WITHIN_DAYS = 7;
|
||||
|
||||
// Off only for plain-http local work. Production is behind TLS.
|
||||
const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false";
|
||||
|
||||
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
|
||||
|
||||
/* ── Passwords ─────────────────────────────────────────────────
|
||||
Format: scrypt$N$r$p$saltHex$keyHex
|
||||
N=16384 r=8 p=1 is the standard interactive cost, roughly
|
||||
100ms per hash on this class of hardware.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 };
|
||||
|
||||
export function hashPassword(password) {
|
||||
const salt = randomBytes(16);
|
||||
const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, {
|
||||
N: SCRYPT.N,
|
||||
r: SCRYPT.r,
|
||||
p: SCRYPT.p,
|
||||
// scrypt's default memory cap is below what N=16384 needs.
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
});
|
||||
return [
|
||||
"scrypt",
|
||||
SCRYPT.N,
|
||||
SCRYPT.r,
|
||||
SCRYPT.p,
|
||||
salt.toString("hex"),
|
||||
key.toString("hex"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export function verifyPassword(password, stored) {
|
||||
if (typeof stored !== "string") return false;
|
||||
|
||||
const [scheme, N, r, p, saltHex, keyHex] = stored.split("$");
|
||||
if (scheme !== "scrypt") return false;
|
||||
|
||||
let candidate;
|
||||
try {
|
||||
candidate = scryptSync(
|
||||
password.normalize("NFKC"),
|
||||
Buffer.from(saltHex, "hex"),
|
||||
Buffer.from(keyHex, "hex").length,
|
||||
{ N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 },
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = Buffer.from(keyHex, "hex");
|
||||
if (candidate.length !== expected.length) return false;
|
||||
return timingSafeEqual(candidate, expected);
|
||||
}
|
||||
|
||||
/* ── Hashing helpers ─────────────────────────────────────────── */
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
export function hashIp(ip) {
|
||||
if (!ip) return null;
|
||||
return sha256(`${IP_SALT}:${ip}`).slice(0, 32);
|
||||
}
|
||||
|
||||
export function clientIp(c) {
|
||||
// Trustworthy only because this service binds to 127.0.0.1 and
|
||||
// nginx is the only thing that can reach it.
|
||||
return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null;
|
||||
}
|
||||
|
||||
/* ── Sessions ────────────────────────────────────────────────── */
|
||||
|
||||
export function createSession(db, c, userId) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash)
|
||||
VALUES (?, ?, datetime('now', ?), ?, ?)`,
|
||||
).run(
|
||||
sha256(token),
|
||||
userId,
|
||||
`+${SESSION_DAYS} days`,
|
||||
c.req.header("user-agent")?.slice(0, 500) ?? null,
|
||||
hashIp(clientIp(c)),
|
||||
);
|
||||
|
||||
setCookie(c, COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: SECURE_COOKIE,
|
||||
// Lax, not Strict: Strict would drop the cookie when someone
|
||||
// follows a link into /admin from elsewhere, which reads as
|
||||
// being randomly signed out. Lax still blocks the cross-site
|
||||
// POST that CSRF depends on.
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function destroySession(db, c) {
|
||||
const token = getCookie(c, COOKIE_NAME);
|
||||
if (token) {
|
||||
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token));
|
||||
}
|
||||
deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE });
|
||||
}
|
||||
|
||||
export function destroyAllSessionsFor(db, userId) {
|
||||
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
||||
}
|
||||
|
||||
/* Returns the signed-in user, or null. Also slides the expiry
|
||||
forward when the session is getting old. */
|
||||
export function currentUser(db, c) {
|
||||
const token = getCookie(c, COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
|
||||
const tokenHash = sha256(token);
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT s.id AS session_id,
|
||||
s.expires_at AS expires_at,
|
||||
u.id, u.email, u.name, u.role
|
||||
FROM sessions s
|
||||
JOIN admin_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ?
|
||||
AND s.expires_at > datetime('now')
|
||||
AND u.is_active = 1`,
|
||||
)
|
||||
.get(tokenHash);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const refreshDue = db
|
||||
.prepare("SELECT ? < datetime('now', ?) AS due")
|
||||
.get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`);
|
||||
|
||||
if (refreshDue?.due) {
|
||||
db.prepare(
|
||||
`UPDATE sessions
|
||||
SET expires_at = datetime('now', ?), last_seen_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(`+${SESSION_DAYS} days`, row.session_id);
|
||||
|
||||
setCookie(c, COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: SECURE_COOKIE,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
|
||||
return { id: row.id, email: row.email, name: row.name, role: row.role };
|
||||
}
|
||||
|
||||
/* ── Middleware ──────────────────────────────────────────────── */
|
||||
|
||||
export async function requireAuth(c, next) {
|
||||
const user = currentUser(c.get("db"), c);
|
||||
if (!user) return c.json({ error: "Not signed in." }, 401);
|
||||
c.set("user", user);
|
||||
await next();
|
||||
}
|
||||
|
||||
export function requireRole(...roles) {
|
||||
return async (c, next) => {
|
||||
const user = c.get("user");
|
||||
if (!user || !roles.includes(user.role)) {
|
||||
return c.json({ error: "Not allowed." }, 403);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Housekeeping ──────────────────────────────────────────────
|
||||
Expired rows are already ignored by every query; this just
|
||||
stops the table growing without bound.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) {
|
||||
const sweep = () => {
|
||||
try {
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
|
||||
} catch (err) {
|
||||
console.error("session sweep failed", err);
|
||||
}
|
||||
};
|
||||
sweep();
|
||||
setInterval(sweep, everyMs).unref();
|
||||
}
|
||||
|
|
@ -15,7 +15,14 @@ import { logger } from "hono/logger";
|
|||
import { openDatabase, migrate } from "./db.js";
|
||||
import { rateLimit } from "./rateLimit.js";
|
||||
import content from "./routes/content.js";
|
||||
import people from "./routes/people.js";
|
||||
import feedback from "./routes/feedback.js";
|
||||
import auth from "./routes/auth.js";
|
||||
import admin from "./routes/admin.js";
|
||||
import adminEntities from "./routes/admin-entities.js";
|
||||
import { startSessionSweeper } from "./auth.js";
|
||||
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
|
||||
import { ENTITIES } from "./admin-schema.js";
|
||||
|
||||
const HOST = process.env.HOST ?? "127.0.0.1";
|
||||
const PORT = Number(process.env.PORT ?? 3001);
|
||||
|
|
@ -25,6 +32,7 @@ const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
|
|||
|
||||
const db = await openDatabase(DB_PATH);
|
||||
const version = migrate(db);
|
||||
syncDescriptorsWithSchema(db, ENTITIES);syncDescriptorsWithSchema(db, ENTITIES);
|
||||
|
||||
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
|
||||
|
||||
|
|
@ -44,11 +52,19 @@ app.get("/api/health", (c) =>
|
|||
);
|
||||
|
||||
app.route("/api", content);
|
||||
app.route("/api", people);
|
||||
|
||||
// Tighter limit on the write path than anything else gets.
|
||||
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
|
||||
app.route("/api/feedback", feedback);
|
||||
|
||||
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
|
||||
app.route("/api/auth", auth);
|
||||
app.route("/api/admin", admin);
|
||||
app.route("/api/admin", adminEntities);
|
||||
|
||||
startSessionSweeper(db);
|
||||
|
||||
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
||||
|
||||
app.onError((err, c) => {
|
||||
|
|
|
|||
55
server/src/migrations/003_auth.sql
Normal file
55
server/src/migrations/003_auth.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 003 AUTHENTICATION
|
||||
--
|
||||
-- Two tables: who may sign in, and who currently is signed in.
|
||||
--
|
||||
-- There is no self-signup and no registration endpoint. Accounts
|
||||
-- are created from the CLI, on the box, by someone with shell
|
||||
-- access. For a handful of staff that's the right trade: no
|
||||
-- invite flow, no email delivery, no password-reset surface for
|
||||
-- anyone to attack.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- Stored lowercased. The application lowercases on every read
|
||||
-- and write, so the UNIQUE index is genuinely case-insensitive
|
||||
-- without depending on a collation.
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
|
||||
-- Nullable so a Google-only account can exist later with no
|
||||
-- password at all. A row with both can use either route in.
|
||||
password_hash TEXT,
|
||||
|
||||
-- Google's stable subject id. Nullable, unique when present —
|
||||
-- SQLite allows any number of NULLs in a unique index.
|
||||
google_sub TEXT UNIQUE,
|
||||
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
CHECK (role IN ('admin', 'viewer')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
last_login_at TEXT
|
||||
) STRICT;
|
||||
|
||||
|
||||
-- One row per active login. The cookie holds a random token; this
|
||||
-- table holds only its SHA-256, so a database leak doesn't hand
|
||||
-- anyone a working session.
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
|
||||
user_agent TEXT,
|
||||
ip_hash TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX sessions_user_idx ON sessions (user_id);
|
||||
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);
|
||||
7
server/src/migrations/004_award_org.sql
Normal file
7
server/src/migrations/004_award_org.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- 004_award_org.sql
|
||||
-- Who gave the award. SET NULL rather than CASCADE: retiring a
|
||||
-- partner org shouldn't erase an award people have received.
|
||||
ALTER TABLE awards
|
||||
ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX awards_org_idx ON awards (org_id, sort_order);
|
||||
28
server/src/migrations/005_person_bio.sql
Normal file
28
server/src/migrations/005_person_bio.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- Person bio and primary organization
|
||||
--
|
||||
-- bio is one run of prose, not orderable mixed content, so it does
|
||||
-- not belong in content_blocks — whose owner_kind CHECK would need
|
||||
-- a full table rebuild to accept 'person' anyway. Paragraphs are
|
||||
-- blank-line separated and split at render time.
|
||||
--
|
||||
-- primary_org_id is nullable on purpose: plenty of people have no
|
||||
-- home organization worth printing, and ON DELETE SET NULL means
|
||||
-- deleting an org blanks the reference rather than blocking the
|
||||
-- delete or leaving a dangling id behind.
|
||||
--
|
||||
-- Check the current version before renumbering this file:
|
||||
-- PRAGMA user_version;
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE people ADD COLUMN bio TEXT;
|
||||
|
||||
-- SQLite requires an added REFERENCES column to default to NULL,
|
||||
-- which is what we want regardless.
|
||||
ALTER TABLE people ADD COLUMN primary_org_id TEXT
|
||||
REFERENCES organizations (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS people_primary_org
|
||||
ON people (primary_org_id);
|
||||
|
||||
PRAGMA user_version = 0; -- ← set to this migration's number
|
||||
45
server/src/migrations/006_leadership_view.sql
Normal file
45
server/src/migrations/006_leadership_view.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- v_org_leadership: add bio and primary organization
|
||||
--
|
||||
-- The view already carries the rules for who counts as current and
|
||||
-- public. Adding the two columns the people tiles need keeps those
|
||||
-- rules in one place instead of being restated by each route.
|
||||
--
|
||||
-- Additive only — attachLeadership does SELECT * and shapeLeader
|
||||
-- picks fields by name, so existing callers are unaffected.
|
||||
--
|
||||
-- PRAGMA user_version; -- check before renumbering this file
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
DROP VIEW IF EXISTS v_org_leadership;
|
||||
|
||||
CREATE VIEW v_org_leadership AS
|
||||
SELECT
|
||||
a.org_id,
|
||||
a.team_id,
|
||||
t.name AS team_name,
|
||||
t.sort_order AS team_sort_order,
|
||||
a.person_id,
|
||||
a.title,
|
||||
a.role,
|
||||
a.is_owner,
|
||||
a.sort_order,
|
||||
p.display_name,
|
||||
p.sort_name,
|
||||
p.pronouns,
|
||||
p.tagline,
|
||||
p.photo,
|
||||
p.public_email,
|
||||
p.location_label,
|
||||
p.bio,
|
||||
o.id AS primary_org_id,
|
||||
o.name AS primary_org_name
|
||||
FROM affiliations a
|
||||
JOIN people p ON p.id = a.person_id AND p.is_published = 1
|
||||
LEFT JOIN teams t ON t.id = a.team_id
|
||||
LEFT JOIN organizations o ON o.id = p.primary_org_id
|
||||
WHERE a.is_public = 1
|
||||
AND a.ended_on IS NULL
|
||||
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
|
||||
|
||||
PRAGMA user_version = 0; -- ← set to this migration's number
|
||||
111
server/src/routes/admin-entities.js
Normal file
111
server/src/routes/admin-entities.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN ENTITY ROUTES
|
||||
|
||||
GET /api/admin/options
|
||||
GET /api/admin/:entity list, filtered and searched
|
||||
GET /api/admin/:entity/:id row with side tables and children
|
||||
POST /api/admin/:entity create
|
||||
PATCH /api/admin/:entity/:id replace the row and its children
|
||||
DELETE /api/admin/:entity/:id
|
||||
|
||||
Mounted alongside the existing admin router, which keeps the
|
||||
feedback routes. Both sit under the same requireAuth.
|
||||
|
||||
:entity is matched against the descriptor map, never
|
||||
interpolated into SQL from the URL — an unknown name is a 404
|
||||
before anything touches the database.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { requireAuth, requireRole } from "../auth.js";
|
||||
import { ENTITIES, OPTION_QUERIES } from "../admin-schema.js";
|
||||
import {
|
||||
HttpError,
|
||||
listRows,
|
||||
readRow,
|
||||
createRow,
|
||||
updateRow,
|
||||
deleteRow,
|
||||
} from "../admin-crud.js";
|
||||
|
||||
const entities = new Hono();
|
||||
|
||||
entities.use("*", requireAuth);
|
||||
|
||||
const NO_STORE = { "Cache-Control": "no-store" };
|
||||
|
||||
function entityOr404(c) {
|
||||
const entity = ENTITIES[c.req.param("entity")];
|
||||
if (!entity) throw new HttpError(404, "Unknown entity.");
|
||||
return entity;
|
||||
}
|
||||
|
||||
/* Everything the select inputs need, in one request. Small enough
|
||||
that paging it would cost more than it saves. */
|
||||
entities.get("/options", (c) => {
|
||||
const db = c.get("db");
|
||||
const options = {};
|
||||
for (const [key, sql] of Object.entries(OPTION_QUERIES)) {
|
||||
options[key] = db.prepare(sql).all();
|
||||
}
|
||||
return c.json({ options }, 200, NO_STORE);
|
||||
});
|
||||
|
||||
entities.get("/:entity", (c) => {
|
||||
const entity = entityOr404(c);
|
||||
const { rows, total } = listRows(c.get("db"), entity, c.req.query());
|
||||
return c.json({ rows, total }, 200, NO_STORE);
|
||||
});
|
||||
|
||||
entities.get("/:entity/:id", (c) => {
|
||||
const entity = entityOr404(c);
|
||||
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
|
||||
});
|
||||
|
||||
entities.post("/:entity", requireRole("admin"), async (c) => {
|
||||
const entity = entityOr404(c);
|
||||
const row = createRow(c.get("db"), entity, await json(c));
|
||||
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
|
||||
return c.json({ row }, 201, NO_STORE);
|
||||
});
|
||||
|
||||
entities.patch("/:entity/:id", requireRole("admin"), async (c) => {
|
||||
const entity = entityOr404(c);
|
||||
const id = c.req.param("id");
|
||||
const row = updateRow(c.get("db"), entity, id, await json(c));
|
||||
console.log(`${entity.key} ${id} updated by ${c.get("user").email}`);
|
||||
return c.json({ row }, 200, NO_STORE);
|
||||
});
|
||||
|
||||
entities.delete("/:entity/:id", requireRole("admin"), (c) => {
|
||||
const entity = entityOr404(c);
|
||||
const id = c.req.param("id");
|
||||
deleteRow(c.get("db"), entity, id);
|
||||
console.log(`${entity.key} ${id} deleted by ${c.get("user").email}`);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
async function json(c) {
|
||||
try {
|
||||
return await c.req.json();
|
||||
} catch {
|
||||
throw new HttpError(400, "Expected a JSON body.");
|
||||
}
|
||||
}
|
||||
|
||||
/* Turn HttpError into a response here rather than letting the
|
||||
app-level handler flatten everything to a 500. */
|
||||
entities.onError((err, c) => {
|
||||
if (err instanceof HttpError) {
|
||||
return c.json(
|
||||
err.fields ? { error: err.message, fields: err.fields } : { error: err.message },
|
||||
err.status,
|
||||
NO_STORE,
|
||||
);
|
||||
}
|
||||
console.error(err);
|
||||
return c.json({ error: "Something went wrong." }, 500);
|
||||
});
|
||||
|
||||
export default entities;
|
||||
172
server/src/routes/admin.js
Normal file
172
server/src/routes/admin.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN ROUTES
|
||||
|
||||
Everything under /api/admin requires a session. Writes require
|
||||
the 'admin' role; a 'viewer' can read and nothing else.
|
||||
|
||||
No response from here is cacheable, and none of it should ever
|
||||
sit in a proxy.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { requireAuth, requireRole } from "../auth.js";
|
||||
|
||||
const admin = new Hono();
|
||||
|
||||
admin.use("*", requireAuth);
|
||||
|
||||
const NO_STORE = { "Cache-Control": "no-store" };
|
||||
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
const MAX_LIMIT = 200;
|
||||
const NOTE_LIMIT = 2000;
|
||||
|
||||
/* ── GET /api/admin/feedback ─────────────────────────────────────
|
||||
?status=new one of STATUSES, or omitted for all
|
||||
?type=broken feedback_type
|
||||
?q=retreat substring of the message
|
||||
?before=41 cursor: rows with a lower id than this
|
||||
?limit=50
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
admin.get("/feedback", (c) => {
|
||||
const db = c.get("db");
|
||||
const { status, type, q, before, limit } = c.req.query();
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (STATUSES.includes(status)) {
|
||||
where.push("status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (type) {
|
||||
where.push("feedback_type = ?");
|
||||
params.push(type.slice(0, 40));
|
||||
}
|
||||
if (q) {
|
||||
where.push("message LIKE ?");
|
||||
params.push(`%${q.slice(0, 100)}%`);
|
||||
}
|
||||
if (before && Number.isInteger(Number(before))) {
|
||||
where.push("id < ?");
|
||||
params.push(Number(before));
|
||||
}
|
||||
|
||||
const take = Math.min(Number(limit) || 50, MAX_LIMIT);
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, created_at, feedback_type, message, name, email,
|
||||
page_path, section_id, status, admin_note
|
||||
FROM feedback
|
||||
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...params, take + 1); // one extra to detect a next page
|
||||
|
||||
const hasMore = rows.length > take;
|
||||
const page = hasMore ? rows.slice(0, take) : rows;
|
||||
|
||||
// Counts are unfiltered on purpose: the tabs should show what's
|
||||
// waiting overall, not what's left after the current filter.
|
||||
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
||||
for (const row of db
|
||||
.prepare("SELECT status, COUNT(*) AS n FROM feedback GROUP BY status")
|
||||
.all()) {
|
||||
counts[row.status] = row.n;
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
feedback: page,
|
||||
counts,
|
||||
nextCursor: hasMore ? page[page.length - 1].id : null,
|
||||
},
|
||||
200,
|
||||
NO_STORE,
|
||||
);
|
||||
});
|
||||
|
||||
/* ── PATCH /api/admin/feedback/:id ───────────────────────────────
|
||||
{ status?, admin_note? } — either, both, partial.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
admin.patch("/feedback/:id", requireRole("admin"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Expected a JSON body." }, 400);
|
||||
}
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
|
||||
if (body.status !== undefined) {
|
||||
if (!STATUSES.includes(body.status)) {
|
||||
return c.json({ error: "Unknown status." }, 422);
|
||||
}
|
||||
sets.push("status = ?");
|
||||
params.push(body.status);
|
||||
}
|
||||
|
||||
if (body.admin_note !== undefined) {
|
||||
const note = String(body.admin_note).trim().slice(0, NOTE_LIMIT);
|
||||
sets.push("admin_note = ?");
|
||||
params.push(note || null);
|
||||
}
|
||||
|
||||
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
|
||||
|
||||
const result = db_update(c.get("db"), id, sets, params);
|
||||
if (!result) return c.json({ error: "No such feedback." }, 404);
|
||||
|
||||
console.log(`feedback #${id} updated by ${c.get("user").email}`);
|
||||
|
||||
return c.json({ feedback: result }, 200, NO_STORE);
|
||||
});
|
||||
|
||||
function db_update(db, id, sets, params) {
|
||||
const changed = db
|
||||
.prepare(`UPDATE feedback SET ${sets.join(", ")} WHERE id = ?`)
|
||||
.run(...params, id);
|
||||
|
||||
if (changed.changes === 0) return null;
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, created_at, feedback_type, message, name, email,
|
||||
page_path, section_id, status, admin_note
|
||||
FROM feedback WHERE id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
/* ── DELETE /api/admin/feedback/:id ──────────────────────────────
|
||||
Actually gone. Marking something 'spam' is the reversible
|
||||
option and should be the habit; this is for the cases where
|
||||
the content itself shouldn't stay on disk.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
admin.delete("/feedback/:id", requireRole("admin"), (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||
|
||||
const result = c
|
||||
.get("db")
|
||||
.prepare("DELETE FROM feedback WHERE id = ?")
|
||||
.run(id);
|
||||
|
||||
if (result.changes === 0) return c.json({ error: "No such feedback." }, 404);
|
||||
|
||||
console.log(`feedback #${id} deleted by ${c.get("user").email}`);
|
||||
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
export default admin;
|
||||
99
server/src/routes/auth.js
Normal file
99
server/src/routes/auth.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
AUTH ROUTES
|
||||
|
||||
POST /api/auth/login email + password → session cookie
|
||||
POST /api/auth/logout deletes the session row and cookie
|
||||
GET /api/auth/me who am I, or 401
|
||||
|
||||
Three deliberate choices:
|
||||
|
||||
Login is rate limited where it's mounted, and answers wrong
|
||||
password and unknown account identically. Telling an attacker
|
||||
which addresses exist is free reconnaissance.
|
||||
|
||||
A failed login still runs a hash. Otherwise the response time
|
||||
itself says whether the account exists.
|
||||
|
||||
Nothing here creates accounts. See admin-cli.js.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Hono } from "hono";
|
||||
|
||||
import {
|
||||
createSession,
|
||||
destroySession,
|
||||
currentUser,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from "../auth.js";
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
// A real hash to compare against when the account doesn't exist,
|
||||
// so both paths cost the same. Computed once at import.
|
||||
const DUMMY_HASH = hashPassword("this password is never correct");
|
||||
|
||||
const NO_STORE = { "Cache-Control": "no-store" };
|
||||
|
||||
auth.post("/login", async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Expected a JSON body." }, 400);
|
||||
}
|
||||
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 254);
|
||||
const password = String(body.password ?? "");
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ error: "Email and password are required." }, 400);
|
||||
}
|
||||
|
||||
const db = c.get("db");
|
||||
|
||||
const user = db
|
||||
.prepare(
|
||||
`SELECT id, email, name, role, password_hash, is_active
|
||||
FROM admin_users
|
||||
WHERE email = ?`,
|
||||
)
|
||||
.get(email);
|
||||
|
||||
const ok =
|
||||
user && user.is_active === 1
|
||||
? verifyPassword(password, user.password_hash)
|
||||
: (verifyPassword(password, DUMMY_HASH), false);
|
||||
|
||||
if (!ok) {
|
||||
console.warn(`login failed for ${email}`);
|
||||
return c.json({ error: "That email and password don't match." }, 401);
|
||||
}
|
||||
|
||||
createSession(db, c, user.id);
|
||||
|
||||
db.prepare("UPDATE admin_users SET last_login_at = datetime('now') WHERE id = ?").run(
|
||||
user.id,
|
||||
);
|
||||
|
||||
console.log(`login ${user.email}`);
|
||||
|
||||
return c.json(
|
||||
{ user: { id: user.id, email: user.email, name: user.name, role: user.role } },
|
||||
200,
|
||||
NO_STORE,
|
||||
);
|
||||
});
|
||||
|
||||
auth.post("/logout", (c) => {
|
||||
destroySession(c.get("db"), c);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
auth.get("/me", (c) => {
|
||||
const user = currentUser(c.get("db"), c);
|
||||
if (!user) return c.json({ error: "Not signed in." }, 401, NO_STORE);
|
||||
return c.json({ user }, 200, NO_STORE);
|
||||
});
|
||||
|
||||
export default auth;
|
||||
141
server/src/routes/people.js
Normal file
141
server/src/routes/people.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PEOPLE ROUTES — read-only, mounted under /api
|
||||
|
||||
GET /teams/:id/people current public members of a team
|
||||
GET /people?ids=a,b,c named people, any order
|
||||
|
||||
The team route reads v_org_leadership, which already decides who
|
||||
counts as current and public — affiliation still open, marked
|
||||
public, person published. Restating those conditions here is how
|
||||
they drift apart.
|
||||
|
||||
That view is affiliation-driven, so it can't serve the lookup
|
||||
route: a person with no affiliations would vanish, and one with
|
||||
several would appear more than once. The lookup reads people
|
||||
directly and returns no title, because a bare slug names no seat.
|
||||
|
||||
Photos are filenames, as everywhere else in this API. The
|
||||
component decides where they live.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { asBool } from "../shape.js";
|
||||
|
||||
const people = new Hono();
|
||||
|
||||
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
||||
|
||||
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
||||
|
||||
const marks = (n) => Array(n).fill("?").join(",");
|
||||
|
||||
/* How many slugs one request may name. Keeps the URL sane. */
|
||||
const MAX_IDS = 50;
|
||||
|
||||
function shapePerson(row) {
|
||||
return {
|
||||
id: row.id ?? row.person_id,
|
||||
name: row.display_name,
|
||||
// Only the team route knows a seat; the lookup route doesn't.
|
||||
title: row.title ?? null,
|
||||
tagline: row.tagline,
|
||||
pronouns: row.pronouns,
|
||||
photo: row.photo,
|
||||
location_label: row.location_label,
|
||||
public_email: row.public_email,
|
||||
org: row.primary_org_id
|
||||
? { id: row.primary_org_id, name: row.primary_org_name }
|
||||
: null,
|
||||
bio: splitParagraphs(row.bio),
|
||||
role: row.role ?? null,
|
||||
is_owner: row.is_owner === undefined ? false : asBool(row.is_owner),
|
||||
};
|
||||
}
|
||||
|
||||
/* people.bio is one run of prose with blank lines between
|
||||
paragraphs — unrelated to shape.js's paragraphs(), which turns
|
||||
content_block rows into text. */
|
||||
function splitParagraphs(text) {
|
||||
if (!text) return null;
|
||||
const parts = text
|
||||
.split(/\n\s*\n/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length ? parts : null;
|
||||
}
|
||||
|
||||
/* ── One team's people ─────────────────────────────────────── */
|
||||
|
||||
people.get("/teams/:id/people", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = c.req.param("id");
|
||||
|
||||
const team = db
|
||||
.prepare(
|
||||
`SELECT id, org_id, name, tagline, color, logo
|
||||
FROM teams
|
||||
WHERE id = ? AND is_published = 1`,
|
||||
)
|
||||
.get(id);
|
||||
|
||||
if (!team) return c.json({ error: "No such team" }, 404);
|
||||
|
||||
// The view orders by org first, which isn't what a single team
|
||||
// wants, so the order is restated here.
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM v_org_leadership
|
||||
WHERE team_id = ?
|
||||
ORDER BY is_owner DESC,
|
||||
sort_order,
|
||||
COALESCE(sort_name, display_name),
|
||||
display_name`,
|
||||
)
|
||||
.all(id);
|
||||
|
||||
return json(c, { team, people: rows.map(shapePerson) });
|
||||
});
|
||||
|
||||
/* ── People by id ──────────────────────────────────────────────
|
||||
For hand-picked lists in a section. Order is the caller's, so
|
||||
there's no ORDER BY here.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
people.get("/people", (c) => {
|
||||
const db = c.get("db");
|
||||
|
||||
const ids = [
|
||||
...new Set(
|
||||
(c.req.query("ids") ?? "")
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, MAX_IDS);
|
||||
|
||||
if (ids.length === 0) return json(c, { people: [] });
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT p.id,
|
||||
p.display_name,
|
||||
p.pronouns,
|
||||
p.photo,
|
||||
p.tagline,
|
||||
p.location_label,
|
||||
p.public_email,
|
||||
p.bio,
|
||||
o.id AS primary_org_id,
|
||||
o.name AS primary_org_name
|
||||
FROM people p
|
||||
LEFT JOIN organizations o ON o.id = p.primary_org_id
|
||||
WHERE p.id IN (${marks(ids.length)})
|
||||
AND p.is_published = 1`,
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
return json(c, { people: rows.map(shapePerson) });
|
||||
});
|
||||
|
||||
export default people;
|
||||
Loading…
Add table
Add a link
Reference in a new issue