NGU-Web/server/src/index.js

104 lines
4.1 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
NGU API
Binds to localhost only. nginx is the only thing that talks to
it, which is what lets the routes trust X-Forwarded-For and skip
CORS entirely — in production the API and the site share an
origin, and in development Vite proxies /api so they share one
there too.
═══════════════════════════════════════════════════════════════ */
import { serve } from "@hono/node-server";
import { Hono } from "hono";
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 history from "./routes/history.js";
import feedback from "./routes/feedback.js";
import auth from "./routes/auth.js";
import admin from "./routes/admin.js";
import panel from "./routes/panel.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);
const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
/* ── Boot ────────────────────────────────────────────────────── */
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})`);
/* ── App ─────────────────────────────────────────────────────── */
const app = new Hono();
app.use("*", logger());
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.get("/api/health", (c) =>
c.json({ ok: true, schema: version, driver: db.driverName }),
);
app.route("/api", content);
app.route("/api", people);
app.route("/api", history);
// 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/admin/panel", panel);
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) => {
console.error(err);
// Never leak internals to the browser.
return c.json({ error: "Something went wrong." }, 500);
});
/* ── Serve ───────────────────────────────────────────────────── */
const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, (info) =>
console.log(`listening http://${info.address}:${info.port}`),
);
/* ── Shutdown ──────────────────────────────────────────────────
systemd sends SIGTERM on stop and restart. Closing the handle
flushes the WAL cleanly, which saves a recovery pass on the
next boot.
───────────────────────────────────────────────────────────── */
for (const signal of ["SIGTERM", "SIGINT"]) {
process.on(signal, () => {
console.log(`${signal} — shutting down`);
server.close(() => {
try {
db.close();
} catch {
/* nothing useful to do here */
}
process.exit(0);
});
});
}