875 lines
40 KiB
JavaScript
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"}`);
|
|
});
|