lxc-keyreg/server.js
2026-08-24 15:21:24 +01:00

875 lines
40 KiB
JavaScript

/* ---------------------------------------------------------------------------
Church key register — server
Node + Express + SQLite. No build step, no ORM, no accounts.
Access control is the network (Tailscale), not the app.
--------------------------------------------------------------------------- */
"use strict";
const path = require("path");
const fs = require("fs");
const express = require("express");
const Database = require("better-sqlite3");
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || "/data";
const DB_PATH = path.join(DATA_DIR, "keyreg.db");
const ACCESS_CODE = process.env.ACCESS_CODE || "";
const UI_LANG = process.env.UI_LANG || "pt";
const REQUEST_PIN = process.env.REQUEST_PIN || "";
/* Requests are staff-only unless you deliberately open them up. See the
README before setting this — it is the one endpoint people outside your
Tailscale can reach. */
const PUBLIC_REQUESTS = process.env.PUBLIC_REQUESTS === "1";
const crypto = require("crypto");
const SESSION_TOKEN = ACCESS_CODE
? crypto.createHash("sha256").update("keyreg:" + ACCESS_CODE).digest("hex").slice(0, 32)
: "";
/* Is this request coming from someone already inside the app?
Used so that handing your own phone to a visitor does not make you type the
shared PIN — the PIN exists to stop strangers on the internet, and you are
standing right there. When no passcode is configured, everyone who can reach
the app is behind Tailscale, so they count as staff unless the form has been
deliberately published. */
function isStaff(req) {
if (!ACCESS_CODE) return !PUBLIC_REQUESTS;
const c = (req.headers.cookie || "").match(/keyreg=([^;]+)/);
return !!(c && c[1] === SESSION_TOKEN);
}
fs.mkdirSync(DATA_DIR, { recursive: true });
/* --------------------------------------------------------------------------
Schema
-------------------------------------------------------------------------- */
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS cabinets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
note TEXT DEFAULT '',
off_site INTEGER DEFAULT 0,
sort INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS doors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
num TEXT NOT NULL UNIQUE,
building TEXT NOT NULL,
name TEXT NOT NULL,
lock_group TEXT DEFAULT '',
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
dept TEXT DEFAULT '',
role TEXT DEFAULT '',
phone TEXT DEFAULT '',
email TEXT DEFAULT '',
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL DEFAULT 'key', -- key | nfc
door_id INTEGER REFERENCES doors(id),
zone TEXT DEFAULT '', -- alarm tags only
building TEXT DEFAULT '',
cabinet_id TEXT REFERENCES cabinets(id),
hook TEXT DEFAULT '',
is_master INTEGER DEFAULT 0,
uid TEXT DEFAULT '', -- NFC chip serial
status TEXT NOT NULL DEFAULT 'in', -- in | out | reserve | lost | retired
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS handovers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id),
person_id INTEGER NOT NULL REFERENCES people(id),
out_date TEXT NOT NULL,
in_date TEXT,
due_date TEXT,
note TEXT DEFAULT '',
issued_by TEXT DEFAULT '',
signature TEXT -- data URL, captured on the phone
);
CREATE TABLE IF NOT EXISTS journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
text TEXT NOT NULL,
author TEXT DEFAULT ''
);
-- Every admin change gets a line. With two of you editing, "who moved 12-B
-- and when" is a question that will come up.
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT DEFAULT ''
);
-- Item types. Started as two hardcoded values (key / nfc); a table instead so
-- you can add padlocks, remotes, fobs or anything else without a code change.
-- shape and colour are what the board draws: shape says what a thing is,
-- fill says where it is.
CREATE TABLE IF NOT EXISTS kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
shape TEXT NOT NULL DEFAULT 'circle', -- circle | rounded | square
colour TEXT NOT NULL DEFAULT '#2E6E60',
sort INTEGER DEFAULT 0
);
-- Access requests. Deliberately separate from people and handovers: a request
-- is something someone asked for, not a decision you made. Nothing here grants
-- anything.
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TEXT NOT NULL,
name TEXT NOT NULL,
dept TEXT DEFAULT '',
contact TEXT DEFAULT '',
areas TEXT DEFAULT '',
detail TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending | handled
handled TEXT,
note TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_handover_item ON handovers(item_id);
CREATE INDEX IF NOT EXISTS idx_handover_open ON handovers(item_id) WHERE in_date IS NULL;
CREATE INDEX IF NOT EXISTS idx_items_door ON items(door_id);
`);
/* Seed the two original types on an existing database, so upgrading needs no
migration step. */
if (db.prepare("SELECT COUNT(*) c FROM kinds").get().c === 0) {
const ins = db.prepare("INSERT INTO kinds (id,name,shape,colour,sort) VALUES (?,?,?,?,?)");
const pt = (process.env.UI_LANG || "pt") === "pt";
ins.run("key", pt ? "Chave" : "Key", "circle", "#2E6E60", 1);
ins.run("nfc", pt ? "Etiqueta de alarme" : "Alarm tag", "rounded", "#5F55A0", 2);
}
const today = () => new Date().toISOString().slice(0, 10);
const now = () => new Date().toISOString();
const audit = (action, detail = "") =>
db.prepare("INSERT INTO audit (ts,action,detail) VALUES (?,?,?)").run(now(), action, detail);
/* --------------------------------------------------------------------------
Demo data — only if the database is empty and --seed-demo is passed
-------------------------------------------------------------------------- */
function seedDemo() {
if (db.prepare("SELECT COUNT(*) c FROM items").get().c > 0) {
console.log("Database already has items — not seeding.");
return;
}
const cabs = [
["SAC", "Sacristia", "Armário da sacristia", "Sacristia, atrás da porta.", 0, 1],
["SEC", "Secretaria", "Armário da secretaria", "Secretaria, armário de parede junto ao cofre.", 0, 2],
["SAL", "Salão", "Armário do salão", "Corredor do salão, ao lado da porta da cozinha.", 0, 3],
["CASA", "Reserva", "Conjunto de reserva — fora", "Caixa selada, fora das instalações. Só para emergências.", 1, 4],
];
const insCab = db.prepare("INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)");
cabs.forEach((c) => insCab.run(...c));
const doors = [
["Igreja", "Entrada principal, poente", "A", 3], ["Igreja", "Sacristia", "B", 3],
["Igreja", "Torre sineira", "C", 2], ["Igreja", "Sala do coro", "D", 2],
["Casa da igreja", "Porta da rua", "E", 4], ["Casa da igreja", "Secretaria", "E", 2],
["Casa da igreja", "Casa das caldeiras", "H", 2], ["Salão", "Porta principal", "J", 3],
["Salão", "Cozinha", "K", 2], ["Exterior", "Portão do cemitério", "L", 2],
["Armários", "Armário da sacristia", "N", 2], ["Armários", "Armário da secretaria", "N", 2],
];
const home = { "Igreja": "SAC", "Casa da igreja": "SEC", "Salão": "SAL",
"Exterior": "SAL", "Armários": "SEC" };
const insDoor = db.prepare("INSERT INTO doors (num,building,name,lock_group) VALUES (?,?,?,?)");
const insItem = db.prepare(
`INSERT INTO items (tag,kind,door_id,building,cabinet_id,hook,is_master,status,notes)
VALUES (?,?,?,?,?,?,?,?,?)`
);
const counters = {};
doors.forEach((d, i) => {
const [building, name, group, copies] = d;
const num = String(i + 1).padStart(2, "0");
const doorId = insDoor.run(num, building, name, group).lastInsertRowid;
for (let c = 0; c < copies; c++) {
const cabinet = home[building];
counters[cabinet] = (counters[cabinet] || 0) + 1;
insItem.run(num + "-" + "ABCDE"[c], "key", doorId, building, cabinet,
`${cabinet}·${String(counters[cabinet]).padStart(2, "0")}`,
group === "A" && c === 0 ? 1 : 0, "in", "");
}
counters.CASA = (counters.CASA || 0) + 1;
insItem.run(num + "-R", "key", doorId, building, "CASA",
`CASA·${String(counters.CASA).padStart(2, "0")}`, 0, "reserve", "");
});
const insNfc = db.prepare(
`INSERT INTO items (tag,kind,zone,building,cabinet_id,hook,uid,status) VALUES (?,?,?,?,?,?,?,?)`
);
[["Igreja", "Nave"], ["Casa da igreja", "Piso térreo"], ["Salão", "Salão e cozinha"]]
.forEach(([b, z], zi) => {
for (let c = 0; c < 4; c++) {
const nn = String(zi * 4 + c + 1).padStart(2, "0");
insNfc.run("N-" + nn, "nfc", z, b, "SEC", "SEC·N" + nn,
"04:" + (0x1a + zi * 4 + c).toString(16).toUpperCase() + ":9C", "in");
}
});
const insPerson = db.prepare("INSERT INTO people (name,dept,role) VALUES (?,?,?)");
[["Maria Fonseca", "Secretaria", "Secretária"],
["João Ribeiro", "Manutenção", "Sacristão"],
["Pe. Anselmo", "Clero", "Padre"],
["Teresa Lopes", "Coro", "Diretora do coro"],
["Sofia Braga", "Reservas do salão", "Voluntária"],
["Paulo Esteves", "Manutenção", "Técnico de aquecimento"]].forEach((p) => insPerson.run(...p));
const give = db.prepare(
"INSERT INTO handovers (item_id,person_id,out_date,in_date,due_date,note,issued_by) VALUES (?,?,?,?,?,?,?)"
);
const setOut = db.prepare("UPDATE items SET status='out' WHERE id=?");
const byTag = db.prepare("SELECT id FROM items WHERE tag=?");
const loan = (tag, pid, out, back, due, note) => {
const it = byTag.get(tag);
if (!it) return;
give.run(it.id, pid, out, back, due, note || "", "Instalação");
if (!back) setOut.run(it.id);
};
loan("02-A", 3, "2019-04-02", null, null, "");
loan("05-A", 1, "2021-09-06", null, null, "");
loan("06-A", 1, "2021-09-06", null, null, "");
loan("01-B", 2, "2018-01-15", null, null, "");
loan("07-A", 2, "2018-01-15", null, null, "");
loan("04-A", 4, "2022-02-14", null, null, "");
loan("08-B", 5, "2026-06-01", null, "2026-08-20", "Concertos de verão");
loan("07-B", 6, "2026-01-12", null, "2026-02-28", "Revisão da caldeira — temporário");
loan("09-B", 5, "2021-05-02", "2023-08-30", null, "");
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
.run(today(), "Registo criado com dados de exemplo. Apague-os em /admin antes de usar a sério.", "Instalação");
audit("seed", "demo data");
console.log("Demo data loaded.");
}
if (process.argv.includes("--seed-demo")) {
seedDemo();
if (process.argv.includes("--exit")) process.exit(0);
}
if (process.env.SEED_DEMO === "1") seedDemo();
/* --------------------------------------------------------------------------
App
-------------------------------------------------------------------------- */
const app = express();
app.use(express.json({ limit: "4mb" })); // signatures are data URLs
app.disable("x-powered-by");
/* Behind a reverse proxy (Nginx Proxy Manager, Cloudflare, Traefik), every
request arrives from the proxy's address. Without this, req.ip is the proxy
for everyone, so the login and PIN rate limits share a single bucket — five
wrong attempts from one attacker would lock out the whole church.
Set TRUST_PROXY=1 for one proxy in front, 2 for two (e.g. Cloudflare → NPM).
Leave it unset when the app is reached directly, otherwise a client can
forge X-Forwarded-For and dodge the limits entirely. */
if (process.env.TRUST_PROXY) {
const hops = Number(process.env.TRUST_PROXY);
app.set("trust proxy", Number.isFinite(hops) ? hops : process.env.TRUST_PROXY);
}
/* Optional shared passcode. Defence in depth only — the real boundary is
Tailscale. Leave ACCESS_CODE empty and there is no gate at all. */
if (ACCESS_CODE) {
/* The cookie holds a hash, not the passcode itself, so a stolen cookie
does not hand over the code you also use elsewhere. */
const TOKEN = SESSION_TOKEN;
/* The login page needs its stylesheet and translations before the user has
a cookie, so these three paths sit outside the gate. */
const OPEN = new Set(["/api/login", "/styles.css", "/i18n.js"]);
if (PUBLIC_REQUESTS) ["/pedido", "/api/request", "/api/request/areas"].forEach((p) => OPEN.add(p));
const attempts = new Map(); // ip -> { n, until }
const loginPage = () =>
fs.readFileSync(path.join(__dirname, "public", "login.html"), "utf8")
.replace("__LANG__", UI_LANG);
app.use((req, res, next) => {
if (OPEN.has(req.path)) return next();
const cookie = (req.headers.cookie || "").match(/keyreg=([^;]+)/);
if (cookie && cookie[1] === TOKEN) return next();
if (req.path.startsWith("/api/")) return res.status(401).json({ error: "locked" });
res.type("html").send(loginPage());
});
app.post("/api/login", (req, res) => {
const ip = req.ip || "?";
const rec = attempts.get(ip) || { n: 0, until: 0 };
if (Date.now() < rec.until) return res.status(429).json({ error: "too many attempts" });
if (req.body.code !== ACCESS_CODE) {
rec.n++;
/* Back off after repeated failures. A shared passcode is short, so
without this it is worth guessing. */
if (rec.n >= 5) { rec.until = Date.now() + 60000; rec.n = 0; }
attempts.set(ip, rec);
audit("login failed", ip);
return res.status(401).json({ error: "wrong code" });
}
attempts.delete(ip);
res.setHeader("Set-Cookie",
`keyreg=${TOKEN}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`);
res.json({ ok: true });
});
}
const bad = (res, msg) => res.status(400).json({ error: msg });
/* ---- read: the whole register in one call. At this size it's simpler and
faster than a dozen endpoints, and the UI never shows a half-state. ---- */
app.get("/api/state", (req, res) => {
const items = db.prepare("SELECT * FROM items ORDER BY tag").all();
const open = db.prepare(`SELECT id,item_id,person_id,out_date,in_date,due_date,note,issued_by,
(signature IS NOT NULL) AS has_sig
FROM handovers WHERE in_date IS NULL`).all();
const openBy = new Map(open.map((h) => [h.item_id, h]));
res.json({
today: today(),
lang: UI_LANG,
kinds: db.prepare("SELECT * FROM kinds ORDER BY sort, name").all(),
cabinets: db.prepare("SELECT * FROM cabinets ORDER BY sort, name").all(),
doors: db.prepare("SELECT * FROM doors ORDER BY CAST(num AS INTEGER), num").all(),
people: db.prepare("SELECT * FROM people ORDER BY name").all(),
items: items.map((i) => ({ ...i, loan: openBy.get(i.id) || null })),
handovers: db.prepare(`SELECT id,item_id,person_id,out_date,in_date,due_date,note,issued_by,
(signature IS NOT NULL) AS has_sig
FROM handovers ORDER BY out_date DESC, id DESC LIMIT 400`).all(),
journal: db.prepare("SELECT * FROM journal ORDER BY date DESC, id DESC LIMIT 200").all(),
pending: db.prepare("SELECT COUNT(*) c FROM requests WHERE status='pending'").get().c,
/* So you can see from inside the app whether the request form is actually
protected, instead of having to remember what is in .env. */
reqForm: { public: PUBLIC_REQUESTS, pin: !!REQUEST_PIN },
});
});
app.get("/api/items/:id/history", (req, res) => {
res.json(db.prepare("SELECT * FROM handovers WHERE item_id=? ORDER BY out_date DESC, id DESC")
.all(req.params.id));
});
/* ---- issue ---- */
app.post("/api/items/:id/issue", (req, res) => {
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
if (!item) return bad(res, "no such item");
if (item.status === "out") return bad(res, "already issued");
if (item.status === "lost" || item.status === "retired") return bad(res, "not issuable");
const { person_id, due_date, note, signature, issued_by } = req.body;
if (!db.prepare("SELECT 1 FROM people WHERE id=?").get(person_id)) return bad(res, "no such person");
const wasReserve = item.status === "reserve";
db.transaction(() => {
db.prepare(`INSERT INTO handovers (item_id,person_id,out_date,due_date,note,issued_by,signature)
VALUES (?,?,?,?,?,?,?)`)
.run(item.id, person_id, today(), due_date || null, note || "", issued_by || "", signature || null);
db.prepare("UPDATE items SET status='out' WHERE id=?").run(item.id);
if (wasReserve) {
const p = db.prepare("SELECT name FROM people WHERE id=?").get(person_id);
const msg = UI_LANG === "pt"
? `Selo de reserva quebrado: ${item.tag} entregue a ${p.name}.`
: `Reserve seal broken: ${item.tag} issued to ${p.name}.`;
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
.run(today(), msg, UI_LANG === "pt" ? "Automático" : "Automatic");
}
})();
audit("issue", `${item.tag} → person ${person_id}`);
res.json({ ok: true });
});
/* ---- return ---- */
app.post("/api/items/:id/return", (req, res) => {
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
if (!item) return bad(res, "no such item");
const open = db.prepare("SELECT * FROM handovers WHERE item_id=? AND in_date IS NULL").get(item.id);
if (!open) return bad(res, "not currently issued");
const cabinet = db.prepare("SELECT off_site FROM cabinets WHERE id=?").get(item.cabinet_id);
db.transaction(() => {
db.prepare("UPDATE handovers SET in_date=?, note=COALESCE(NULLIF(?,''),note) WHERE id=?")
.run(today(), req.body.note || "", open.id);
/* a key goes back to whatever its home cabinet implies */
db.prepare("UPDATE items SET status=? WHERE id=?")
.run(cabinet && cabinet.off_site ? "reserve" : "in", item.id);
})();
audit("return", item.tag);
res.json({ ok: true });
});
/* ---- notes and journal ---- */
app.patch("/api/items/:id/notes", (req, res) => {
db.prepare("UPDATE items SET notes=? WHERE id=?").run(req.body.notes || "", req.params.id);
res.json({ ok: true });
});
app.post("/api/journal", (req, res) => {
if (!req.body.text || !req.body.text.trim()) return bad(res, "empty");
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
.run(req.body.date || today(), req.body.text.trim(), req.body.author || "");
res.json({ ok: true });
});
/* --------------------------------------------------------------------------
Access requests
-------------------------------------------------------------------------- */
const reqHits = new Map(); // ip -> { n, until }
/* Areas only — never the door list. If this form is publicly reachable, a
list of every door is a map of the building for anyone who finds the page. */
app.get("/api/request/areas", (req, res) => {
const rows = db.prepare("SELECT DISTINCT building FROM doors WHERE building<>'' ORDER BY building").all();
res.json({ areas: rows.map((r) => r.building), pin: !!REQUEST_PIN && !isStaff(req) });
});
app.post("/api/request", (req, res) => {
const ip = req.ip || "?";
const hit = reqHits.get(ip) || { n: 0, until: 0 };
if (Date.now() < hit.until) return res.status(429).json({ error: "too many requests" });
/* Hidden field a person never sees. Bots fill every input they find. */
if (req.body.website) return res.json({ ok: true });
if (REQUEST_PIN && !isStaff(req) && String(req.body.pin || "").trim() !== REQUEST_PIN) {
hit.n++;
if (hit.n >= 5) { hit.until = Date.now() + 300000; hit.n = 0; }
reqHits.set(ip, hit);
return res.status(401).json({ error: "wrong pin" });
}
const clip = (v, n) => String(v || "").trim().slice(0, n);
const name = clip(req.body.name, 80);
if (!name) return bad(res, "name required");
const areas = Array.isArray(req.body.areas)
? req.body.areas.map((a) => clip(a, 60)).filter(Boolean).slice(0, 12).join(", ")
: clip(req.body.areas, 200);
const dept = clip(req.body.dept, 80);
const contact = clip(req.body.contact, 80);
const detail = clip(req.body.detail, 600);
hit.n = (hit.n || 0) + 1;
if (hit.n >= 8) hit.until = Date.now() + 300000;
reqHits.set(ip, hit);
db.transaction(() => {
db.prepare(`INSERT INTO requests (created,name,dept,contact,areas,detail)
VALUES (?,?,?,?,?,?)`).run(now(), name, dept, contact, areas, detail);
const label = UI_LANG === "pt" ? "Pedido de acesso" : "Access request";
const via = isStaff(req)
? (UI_LANG === "pt" ? "Formulário (no local)" : "Form (in person)")
: (UI_LANG === "pt" ? "Formulário (online)" : "Form (online)");
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)").run(
today(),
`${label}: ${name}${dept ? " (" + dept + ")" : ""}` +
`${areas ? " — " + areas : ""}${detail ? ". " + detail : ""}`,
via);
})();
audit("request", name);
res.json({ ok: true });
});
app.get("/api/requests", (req, res) => {
res.json(db.prepare("SELECT * FROM requests ORDER BY status='handled', id DESC LIMIT 100").all());
});
app.post("/api/requests/:id/handle", (req, res) => {
const r = db.prepare("SELECT * FROM requests WHERE id=?").get(req.params.id);
if (!r) return bad(res, "no such request");
db.prepare("UPDATE requests SET status='handled', handled=?, note=? WHERE id=?")
.run(today(), String(req.body.note || "").slice(0, 300), r.id);
audit("request handled", r.name);
res.json({ ok: true });
});
/* --------------------------------------------------------------------------
Admin
-------------------------------------------------------------------------- */
const A = "/api/admin";
app.post(A + "/cabinets", (req, res) => {
const { id, name, full_name, note, off_site, sort } = req.body;
if (!id || !name) return bad(res, "id and name required");
db.prepare(`INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, full_name=excluded.full_name,
note=excluded.note, off_site=excluded.off_site, sort=excluded.sort`)
.run(id, name, full_name || name, note || "", off_site ? 1 : 0, sort || 0);
audit("cabinet", id);
res.json({ ok: true });
});
app.delete(A + "/cabinets/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE cabinet_id=?").get(req.params.id).c;
if (n) return bad(res, `${n} keys still live in this cabinet — move them first`);
db.prepare("DELETE FROM cabinets WHERE id=?").run(req.params.id);
audit("cabinet delete", req.params.id);
res.json({ ok: true });
});
app.post(A + "/kinds", (req, res) => {
const { id, name, shape, colour, sort } = req.body;
if (!id || !name) return bad(res, "id and name required");
const code = String(id).trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
if (!code) return bad(res, "id must contain letters or numbers");
db.prepare(`INSERT INTO kinds (id,name,shape,colour,sort) VALUES (?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, shape=excluded.shape,
colour=excluded.colour, sort=excluded.sort`)
.run(code, String(name).trim(),
["circle", "rounded", "square"].includes(shape) ? shape : "circle",
/^#[0-9a-fA-F]{6}$/.test(colour || "") ? colour : "#2E6E60", +sort || 0);
audit("kind", code);
res.json({ ok: true });
});
app.delete(A + "/kinds/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE kind=?").get(req.params.id).c;
if (n) return bad(res, `${n} items still use this type — change them first`);
if (db.prepare("SELECT COUNT(*) c FROM kinds").get().c <= 1) return bad(res, "at least one type is required");
db.prepare("DELETE FROM kinds WHERE id=?").run(req.params.id);
audit("kind delete", req.params.id);
res.json({ ok: true });
});
app.post(A + "/doors", (req, res) => {
const { id, num, building, name, lock_group, notes } = req.body;
if (!num || !building || !name) return bad(res, "num, building and name required");
try {
if (id) {
db.prepare("UPDATE doors SET num=?,building=?,name=?,lock_group=?,notes=? WHERE id=?")
.run(num, building, name, lock_group || "", notes || "", id);
db.prepare("UPDATE items SET building=? WHERE door_id=?").run(building, id);
} else {
db.prepare("INSERT INTO doors (num,building,name,lock_group,notes) VALUES (?,?,?,?,?)")
.run(num, building, name, lock_group || "", notes || "");
}
} catch (e) { return bad(res, "door number already used"); }
audit("door", num + " " + name);
res.json({ ok: true });
});
app.delete(A + "/doors/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE door_id=?").get(req.params.id).c;
if (n) return bad(res, `${n} keys belong to this door — delete or move them first`);
db.prepare("DELETE FROM doors WHERE id=?").run(req.params.id);
audit("door delete", req.params.id);
res.json({ ok: true });
});
app.post(A + "/people", (req, res) => {
const { id, name, dept, role, phone, email, active } = req.body;
if (!name) return bad(res, "name required");
if (id) {
db.prepare("UPDATE people SET name=?,dept=?,role=?,phone=?,email=?,active=? WHERE id=?")
.run(name, dept || "", role || "", phone || "", email || "", active ? 1 : 0, id);
} else {
db.prepare("INSERT INTO people (name,dept,role,phone,email) VALUES (?,?,?,?,?)")
.run(name, dept || "", role || "", phone || "", email || "");
}
audit("person", name);
res.json({ ok: true });
});
/* People are never deleted — they appear in old handovers. Deactivate instead. */
app.delete(A + "/people/:id", (req, res) => {
db.prepare("UPDATE people SET active=0 WHERE id=?").run(req.params.id);
audit("person deactivate", req.params.id);
res.json({ ok: true, note: "deactivated, not deleted — they appear in past handovers" });
});
app.post(A + "/items", (req, res) => {
const { id, tag, kind, door_id, zone, cabinet_id, hook, is_master, uid, status, notes } = req.body;
if (!tag) return bad(res, "tag required");
const door = door_id ? db.prepare("SELECT * FROM doors WHERE id=?").get(door_id) : null;
const building = door ? door.building : req.body.building || "";
const theKind = db.prepare("SELECT id FROM kinds WHERE id=?").get(kind || "key")
? (kind || "key")
: db.prepare("SELECT id FROM kinds ORDER BY sort LIMIT 1").get().id;
try {
if (id) {
db.prepare(`UPDATE items SET tag=?,kind=?,door_id=?,zone=?,building=?,cabinet_id=?,hook=?,
is_master=?,uid=?,status=?,notes=? WHERE id=?`)
.run(tag, theKind, door_id || null, zone || "", building, cabinet_id || null,
hook || "", is_master ? 1 : 0, uid || "", status || "in", notes || "", id);
} else {
db.prepare(`INSERT INTO items (tag,kind,door_id,zone,building,cabinet_id,hook,is_master,uid,status,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
.run(tag, theKind, door_id || null, zone || "", building, cabinet_id || null,
hook || "", is_master ? 1 : 0, uid || "", status || "in", notes || "");
}
} catch (e) { return bad(res, "that tag is already used"); }
audit("item", tag);
res.json({ ok: true });
});
/* Items are retired, not deleted — their handover history must survive. */
app.delete(A + "/items/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM handovers WHERE item_id=?").get(req.params.id).c;
if (n) {
db.prepare("UPDATE items SET status='retired' WHERE id=?").run(req.params.id);
audit("item retire", req.params.id);
return res.json({ ok: true, note: "retired — it has handover history that must be kept" });
}
db.prepare("DELETE FROM items WHERE id=?").run(req.params.id);
audit("item delete", req.params.id);
res.json({ ok: true });
});
/* ---- CSV import: the thing that decides whether this project ever starts ---- */
function parseCSV(text) {
const rows = [];
let row = [], cell = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
else if (c === '"') q = false;
else cell += c;
} else if (c === '"') q = true;
else if (c === "," || c === ";" || c === "\t") { row.push(cell); cell = ""; }
else if (c === "\n") { row.push(cell); rows.push(row); row = []; cell = ""; }
else if (c !== "\r") cell += c;
}
if (cell || row.length) { row.push(cell); rows.push(row); }
return rows.filter((r) => r.some((x) => x.trim() !== ""));
}
app.post(A + "/import", (req, res) => {
const rows = parseCSV(req.body.csv || "");
if (rows.length < 2) return bad(res, "needs a header row and at least one key");
const head = rows[0].map((h) => h.trim().toLowerCase());
const col = (n) => head.indexOf(n);
const need = ["tag", "door", "building"];
const missing = need.filter((n) => col(n) < 0);
if (missing.length) return bad(res, "missing columns: " + missing.join(", "));
const findDoor = db.prepare("SELECT id FROM doors WHERE name=? AND building=?");
const nextNum = () => {
const m = db.prepare("SELECT MAX(CAST(num AS INTEGER)) m FROM doors").get().m || 0;
return String(m + 1).padStart(2, "0");
};
const out = { doors: 0, items: 0, cabinets: 0, skipped: [] };
db.transaction(() => {
for (const r of rows.slice(1)) {
const tag = (r[col("tag")] || "").trim();
const doorName = (r[col("door")] || "").trim();
const building = (r[col("building")] || "").trim();
if (!tag || !doorName || !building) { out.skipped.push(tag || "(no tag)"); continue; }
if (db.prepare("SELECT 1 FROM items WHERE tag=?").get(tag)) {
out.skipped.push(tag + " (already exists)"); continue;
}
const group = col("lock_group") >= 0 ? (r[col("lock_group")] || "").trim() : "";
let door = findDoor.get(doorName, building);
if (!door) {
const id = db.prepare("INSERT INTO doors (num,building,name,lock_group) VALUES (?,?,?,?)")
.run(nextNum(), building, doorName, group).lastInsertRowid;
door = { id }; out.doors++;
}
let cabId = col("cabinet") >= 0 ? (r[col("cabinet")] || "").trim() : "";
if (cabId && !db.prepare("SELECT 1 FROM cabinets WHERE id=?").get(cabId)) {
db.prepare("INSERT INTO cabinets (id,name,full_name) VALUES (?,?,?)").run(cabId, cabId, cabId);
out.cabinets++;
}
/* An unknown type is refused rather than created. A malformed row or a
typo would otherwise litter the type list with junk, and types are now
two clicks away in Admin. */
let kindId = col("kind") >= 0 ? (r[col("kind")] || "").trim().toLowerCase() : "";
if (!kindId) kindId = db.prepare("SELECT id FROM kinds ORDER BY sort LIMIT 1").get().id;
if (!db.prepare("SELECT 1 FROM kinds WHERE id=?").get(kindId)) {
out.skipped.push(`${tag} (unknown type: ${kindId})`);
continue;
}
db.prepare(`INSERT INTO items (tag,kind,door_id,building,cabinet_id,hook,is_master,status,notes)
VALUES (?,?,?,?,?,?,?,?,?)`)
.run(tag,
kindId,
door.id, building, cabId || null,
col("hook") >= 0 ? (r[col("hook")] || "").trim() : "",
col("master") >= 0 && /^(1|yes|y|true)$/i.test((r[col("master")] || "").trim()) ? 1 : 0,
col("status") >= 0 && (r[col("status")] || "").trim() ? (r[col("status")]).trim() : "in",
col("notes") >= 0 ? (r[col("notes")] || "").trim() : "");
out.items++;
}
})();
audit("import", `${out.items} items, ${out.doors} doors`);
res.json(out);
});
/* Separate importers. The combined one creates doors and cabinets implicitly,
which is convenient but gives you no control over numbering or names. These
let you load each table deliberately, and re-run safely to update. */
function importer(res, csv, required, handle) {
const rows = parseCSV(csv || "");
if (rows.length < 2) return bad(res, "needs a header row and at least one line");
const head = rows[0].map((h) => h.trim().toLowerCase());
const col = (n) => head.indexOf(n);
const missing = required.filter((n) => col(n) < 0);
if (missing.length) return bad(res, "missing columns: " + missing.join(", "));
const get = (r, n) => (col(n) >= 0 ? (r[col(n)] || "").trim() : "");
/* Whether the column exists at all in this file. An absent column must not
overwrite existing data with blanks — re-importing `name,role` should
update the role and leave the department alone. */
const has = (n) => col(n) >= 0;
const out = { created: 0, updated: 0, skipped: [] };
db.transaction(() => { rows.slice(1).forEach((r) => handle(r, get, out, has)); })();
return out;
}
const truthy = (v) => /^(1|yes|y|true|sim|s|x)$/i.test(v);
app.post(A + "/import/doors", (req, res) => {
const out = importer(res, req.body.csv, ["num", "building", "name"], (r, get, o, has) => {
const num = get(r, "num"), building = get(r, "building"), name = get(r, "name");
if (!num || !building || !name) { o.skipped.push(num || "(sem número)"); return; }
const exists = db.prepare("SELECT id FROM doors WHERE num=?").get(num);
if (exists) {
const set = { building, name };
if (has("lock_group")) set.lock_group = get(r, "lock_group");
if (has("notes")) set.notes = get(r, "notes");
const cols = Object.keys(set);
db.prepare(`UPDATE doors SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
.run(...cols.map((c) => set[c]), exists.id);
db.prepare("UPDATE items SET building=? WHERE door_id=?").run(building, exists.id);
o.updated++;
} else {
db.prepare("INSERT INTO doors (num,building,name,lock_group,notes) VALUES (?,?,?,?,?)")
.run(num, building, name, get(r, "lock_group"), get(r, "notes"));
o.created++;
}
});
if (!out || out.error) return;
audit("import doors", `${out.created} new, ${out.updated} updated`);
res.json(out);
});
app.post(A + "/import/people", (req, res) => {
const out = importer(res, req.body.csv, ["name"], (r, get, o, has) => {
const name = get(r, "name");
if (!name) { o.skipped.push("(sem nome)"); return; }
/* Matched on name, so re-importing an updated spreadsheet edits people
rather than creating duplicates. */
const exists = db.prepare("SELECT id FROM people WHERE name=? COLLATE NOCASE").get(name);
if (exists) {
const set = {};
["dept", "role", "phone", "email"].forEach((c) => { if (has(c)) set[c] = get(r, c); });
if (has("active")) set.active = col_active(r, get);
const cols = Object.keys(set);
if (cols.length) {
db.prepare(`UPDATE people SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
.run(...cols.map((c) => set[c]), exists.id);
}
o.updated++;
} else {
db.prepare("INSERT INTO people (name,dept,role,phone,email,active) VALUES (?,?,?,?,?,?)")
.run(name, get(r, "dept"), get(r, "role"), get(r, "phone"), get(r, "email"), col_active(r, get));
o.created++;
}
});
if (!out || out.error) return;
audit("import people", `${out.created} new, ${out.updated} updated`);
res.json(out);
});
function col_active(r, get) {
const v = get(r, "active");
return v === "" ? 1 : truthy(v) ? 1 : 0;
}
app.post(A + "/import/cabinets", (req, res) => {
const out = importer(res, req.body.csv, ["id", "name"], (r, get, o, has) => {
const id = get(r, "id").toUpperCase(), name = get(r, "name");
if (!id || !name) { o.skipped.push(id || "(sem código)"); return; }
const exists = db.prepare("SELECT * FROM cabinets WHERE id=?").get(id);
if (exists) {
const set = { name };
if (has("full_name")) set.full_name = get(r, "full_name") || name;
if (has("note")) set.note = get(r, "note");
if (has("off_site")) set.off_site = truthy(get(r, "off_site")) ? 1 : 0;
if (has("sort")) set.sort = +get(r, "sort") || 0;
const cols = Object.keys(set);
db.prepare(`UPDATE cabinets SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
.run(...cols.map((c) => set[c]), id);
o.updated++;
} else {
db.prepare("INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)")
.run(id, name, get(r, "full_name") || name, get(r, "note"),
truthy(get(r, "off_site")) ? 1 : 0, +get(r, "sort") || 0);
o.created++;
}
});
if (!out || out.error) return;
audit("import cabinets", `${out.created} new, ${out.updated} updated`);
res.json(out);
});
/* Signatures for one person, for the printable receipt. Kept out of
/api/state so the images are only fetched when actually shown. */
app.get("/api/report/person/:id", (req, res) => {
res.json(db.prepare(`SELECT h.*, i.tag, d.name AS door, i.building, i.cabinet_id
FROM handovers h JOIN items i ON i.id=h.item_id
LEFT JOIN doors d ON d.id=i.door_id
WHERE h.person_id=? AND h.in_date IS NULL
ORDER BY h.out_date DESC`).all(req.params.id));
});
app.get(A + "/export.csv", (req, res) => {
const rows = db.prepare(`
SELECT i.tag, d.name AS door, i.building, d.lock_group, i.cabinet_id AS cabinet, i.hook,
i.is_master AS master, i.status, i.kind, i.uid, i.notes,
(SELECT p.name FROM handovers h JOIN people p ON p.id=h.person_id
WHERE h.item_id=i.id AND h.in_date IS NULL) AS held_by
FROM items i LEFT JOIN doors d ON d.id=i.door_id ORDER BY i.tag`).all();
const head = Object.keys(rows[0] || { tag: "" });
const q = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="key-register-${today()}.csv"`);
res.send([head.join(","), ...rows.map((r) => head.map((h) => q(r[h])).join(","))].join("\n"));
});
/* Consistent snapshot even with writes in flight — this is why SQLite is a
good choice here: the whole backup is one file. */
app.get(A + "/backup.db", (req, res) => {
const tmp = path.join(DATA_DIR, `backup-${Date.now()}.db`);
db.backup(tmp).then(() => {
res.setHeader("Content-Disposition", `attachment; filename="keyreg-${today()}.db"`);
res.sendFile(tmp, () => fs.unlink(tmp, () => {}));
}).catch((e) => res.status(500).json({ error: String(e) }));
});
app.get(A + "/audit", (req, res) => {
res.json(db.prepare("SELECT * FROM audit ORDER BY id DESC LIMIT 200").all());
});
app.post(A + "/wipe", (req, res) => {
if (req.body.confirm !== "DELETE EVERYTHING") return bad(res, "confirmation phrase required");
db.transaction(() => {
["handovers", "items", "doors", "people", "journal"].forEach((t) => db.prepare(`DELETE FROM ${t}`).run());
})();
audit("wipe", "all register data cleared");
res.json({ ok: true });
});
/* --------------------------------------------------------------------------
Static
-------------------------------------------------------------------------- */
app.use(express.static(path.join(__dirname, "public")));
app.get("/admin", (req, res) => res.sendFile(path.join(__dirname, "public", "admin.html")));
app.get("/report", (req, res) => res.sendFile(path.join(__dirname, "public", "report.html")));
app.get("/pedido", (req, res) => {
res.type("html").send(
fs.readFileSync(path.join(__dirname, "public", "pedido.html"), "utf8")
.replace("__LANG__", UI_LANG));
});
app.get("/health", (req, res) => res.json({ ok: true, items: db.prepare("SELECT COUNT(*) c FROM items").get().c }));
app.listen(PORT, "0.0.0.0", () => {
console.log(`Key register on http://0.0.0.0:${PORT} (db: ${DB_PATH})`);
if (!ACCESS_CODE) console.log("No ACCESS_CODE set — relying on the network for access control.");
if (PUBLIC_REQUESTS) console.log(`Request form is PUBLIC at /pedido${REQUEST_PIN ? " (PIN required)" : " — NO PIN SET"}`);
});