// Spike 50 Parts Board — tiny self-hosted backend. // No npm dependencies: Node 22+ built-ins only (http, fs, node:sqlite). "use strict"; const http = require("node:http"); const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); const { DatabaseSync } = require("node:sqlite"); const PORT = +(process.env.PORT || 8080); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data"); const PUBLIC_DIR = path.join(__dirname, "public"); const PASS = process.env.BOARD_PASSWORD || ""; const MAX_UPLOAD = 20 * 1024 * 1024; const MAX_PHOTO_REQ = 30 * 1024 * 1024; function dataUrlJpeg(s) { const m = /^data:image\/jpeg;base64,([A-Za-z0-9+/=]+)$/.exec(String(s || "")); if (!m) return null; const buf = Buffer.from(m[1], "base64"); return buf.length > 8 && buf[0] === 0xff && buf[1] === 0xd8 ? buf : null; } fs.mkdirSync(path.join(DATA_DIR, "images"), { recursive: true }); const db = new DatabaseSync(path.join(DATA_DIR, "board.db")); db.exec(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS parts (id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'none', note TEXT NOT NULL DEFAULT '', updated INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS links (id INTEGER PRIMARY KEY AUTOINCREMENT, part_id TEXT NOT NULL, url TEXT NOT NULL, label TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY AUTOINCREMENT, part_id TEXT NOT NULL, text TEXT NOT NULL, created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS drawings (key TEXT PRIMARY KEY, pins TEXT, image TEXT, updated INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS photos (id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, caption TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS photos_scope ON photos(scope); CREATE INDEX IF NOT EXISTS links_part ON links(part_id); CREATE INDEX IF NOT EXISTS comments_part ON comments(part_id); `); for (const sql of [ "ALTER TABLE parts ADD COLUMN updated_by TEXT NOT NULL DEFAULT ''", "ALTER TABLE links ADD COLUMN author TEXT NOT NULL DEFAULT ''", "ALTER TABLE comments ADD COLUMN author TEXT NOT NULL DEFAULT ''", "ALTER TABLE photos ADD COLUMN author TEXT NOT NULL DEFAULT ''", ]) { try { db.exec(sql); } catch {} } db.exec(` CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, must_change INTEGER NOT NULL DEFAULT 0, ver INTEGER NOT NULL DEFAULT 1, last_seen INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, part_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS activity_part ON activity(part_id); CREATE TABLE IF NOT EXISTS spending (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, cents INTEGER NOT NULL, what TEXT NOT NULL, created INTEGER NOT NULL); `); // ---------- users ---------- const hashPw = (pw, salt) => crypto.scryptSync(String(pw), salt, 32).toString("hex"); function setPassword(name, pw, mustChange) { const salt = crypto.randomBytes(16).toString("hex"); db.prepare("UPDATE users SET salt = ?, hash = ?, must_change = ?, ver = ver + 1 WHERE name = ?").run(salt, hashPw(pw, salt), mustChange ? 1 : 0, name); } function addUser(name, pw, mustChange) { db.prepare("INSERT OR IGNORE INTO users (name, salt, hash, must_change) VALUES (?, '', '', 0)").run(name); setPassword(name, pw, mustChange); } if (!db.prepare("SELECT 1 FROM users LIMIT 1").get()) { addUser("flo", PASS || "lolo", false); // keeps the current password addUser("andi", "lolo", true); // must choose a new one after the first login addUser("lolo", "lolo", true); } const NAME_RE = /^[a-z0-9_]{2,20}$/; function checkLogin(name, pw) { const u = db.prepare("SELECT * FROM users WHERE name = ?").get(String(name || "").trim().toLowerCase()); // always hash, so unknown names cost the same time as wrong passwords const h = hashPw(pw, u ? u.salt : "x"); return u && safeEq(h, u.hash) ? u : null; } const seen = new Map(); function touch(name) { const now = Date.now(); if (now - (seen.get(name) || 0) < 20000) return; seen.set(name, now); db.prepare("UPDATE users SET last_seen = ? WHERE name = ?").run(now, name); } function logActivity(author, part, action, detail = "") { const last = db.prepare("SELECT id, author, part_id, action, detail, created FROM activity ORDER BY id DESC LIMIT 1").get(); if (action === "markers" && last && last.author === author && last.action === action && last.detail === detail && Date.now() - last.created < 600000) return; db.prepare("INSERT INTO activity (author, part_id, action, detail, created) VALUES (?, ?, ?, ?, ?)").run(author, part, action, cleanText(detail, 300), Date.now()); } const STATUSES = new Set(["none", "have", "miss", "ord", "skip"]); const ID_RE = /^[a-z0-9-]{1,60}__[0-9]{1,6}$/; const KEY_RE = /^[a-z0-9-]{1,60}$/; const IMG_TYPES = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif" }; const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json" }; // ---------- helpers ---------- function send(res, code, body, type = "application/json") { const data = type === "application/json" ? JSON.stringify(body) : body; res.writeHead(code, { "Content-Type": type, "Cache-Control": "no-store" }); res.end(data); } const fail = (res, code, msg) => send(res, code, { error: msg }); function readBody(req, limit) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on("data", c => { size += c.length; if (size > limit) { reject(Object.assign(new Error("too large"), { code: 413 })); req.destroy(); } else chunks.push(c); }); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } async function readJson(req) { const buf = await readBody(req, 1024 * 1024); try { return JSON.parse(buf.toString("utf8") || "{}"); } catch { throw Object.assign(new Error("bad json"), { code: 400 }); } } const SECRET_FILE = path.join(DATA_DIR, "session-secret"); let SECRET; try { SECRET = fs.readFileSync(SECRET_FILE); } catch { SECRET = crypto.randomBytes(32); fs.writeFileSync(SECRET_FILE, SECRET, { mode: 0o600 }); } const SESSION_MS = 30 * 24 * 3600 * 1000; const sign = (v) => crypto.createHmac("sha256", SECRET).update(v).digest("base64url"); function safeEq(x, y) { const a = Buffer.from(String(x)), b = Buffer.from(String(y)); return a.length === b.length && crypto.timingSafeEqual(a, b); } const makeSession = (u) => { const body = `${u.name}.${u.ver}.${Date.now() + SESSION_MS}`; return `${body}.${sign(body)}`; }; function sessionUser(req) { const m = /(?:^|;\s*)spike_session=([^;]+)/.exec(req.headers.cookie || ""); if (!m) return null; const parts = m[1].split("."); if (parts.length !== 4) return null; const [name, ver, exp, sig] = parts; if (!safeEq(sig, sign(`${name}.${ver}.${exp}`)) || +exp < Date.now()) return null; const u = db.prepare("SELECT * FROM users WHERE name = ?").get(name); return u && u.ver === +ver ? u : null; } function authUser(req) { const u = sessionUser(req); if (u) return u; const h = req.headers.authorization || ""; if (h.startsWith("Basic ")) { const [n, p] = Buffer.from(h.slice(6), "base64").toString().split(/:(.*)/s); return checkLogin(n, p); } return null; } const COOKIE = "Path=/; HttpOnly; Secure; SameSite=Lax"; const setCookie = (u) => `spike_session=${makeSession(u)}; ${COOKIE}; Max-Age=${SESSION_MS / 1000}`; const PAGE = (title, inner) => `${title}${inner}`; const LOGIN_HTML = (err) => PAGE("Spike Parts Board - Login", `

Spike 50 Parts Board

Sign in to continue

${err ? `
${err}
` : ""}
`); const PASSWORD_HTML = (u, err) => PAGE("Spike Parts Board - Password", `

${u.must_change ? "Choose a new password" : "Change password"}

${u.must_change ? `Welcome ${u.name}! Before you continue, set your own password.` : `Signed in as ${u.name}.`}

${err ? `
${err}
` : ""}${u.must_change ? "" : '

Back to the board

'}
`); const fails = new Map(); const clientIp = (req) => String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim(); const cleanText = (s, max) => String(s ?? "").slice(0, max); function cleanUrl(s) { try { const u = new URL(String(s).trim()); return ["http:", "https:"].includes(u.protocol) ? u.href : null; } catch { return null; } } // ---------- state ---------- function fullState(me) { const parts = {}, links = {}, comments = {}, drawings = {}, photos = {}; for (const r of db.prepare("SELECT id, status, note, updated, updated_by FROM parts").all()) parts[r.id] = { s: r.status, note: r.note, by: r.updated_by, at: r.updated }; for (const r of db.prepare("SELECT id, part_id, url, label, created, author FROM links ORDER BY created").all()) (links[r.part_id] ||= []).push({ id: r.id, url: r.url, label: r.label, at: r.created, by: r.author }); for (const r of db.prepare("SELECT id, part_id, text, created, author FROM comments ORDER BY created").all()) (comments[r.part_id] ||= []).push({ id: r.id, text: r.text, at: r.created, by: r.author }); for (const r of db.prepare("SELECT key, pins, image, updated FROM drawings").all()) drawings[r.key] = { pins: r.pins ? JSON.parse(r.pins) : null, image: r.image ? `uploads/${r.image}?v=${r.updated}` : null }; for (const r of db.prepare("SELECT id, scope, caption, created, author FROM photos ORDER BY created DESC, id DESC").all()) (photos[r.scope] ||= []).push({ id: r.id, caption: r.caption, at: r.created, by: r.author, src: `uploads/photo-${r.id}.jpg`, thumb: `uploads/photo-${r.id}-t.jpg` }); const now = Date.now(); const users = db.prepare("SELECT name, last_seen FROM users ORDER BY name").all().map((u) => ({ name: u.name, online: u.name === me || now - u.last_seen < 90000, last: u.last_seen })); const spending = db.prepare("SELECT id, author, cents, what, created FROM spending ORDER BY created DESC, id DESC").all().map((r) => ({ id: r.id, by: r.author, cents: r.cents, what: r.what, at: r.created })); const activity = db.prepare("SELECT id, author, part_id, action, detail, created FROM activity ORDER BY id DESC LIMIT 300").all().map((r) => ({ id: r.id, by: r.author, part: r.part_id, action: r.action, detail: r.detail, at: r.created })); return { parts, links, comments, drawings, photos, users, me, spending, activity }; } // ---------- routes ---------- async function api(req, res, url) { const m = (re) => url.pathname.match(re); let p; if (req.method === "GET" && url.pathname === "/api/ping") return send(res, 200, { ok: true }); if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, fullState(req.user.name)); if (req.method === "GET" && url.pathname === "/api/export") { res.setHeader("Content-Disposition", `attachment; filename="spike-board-${new Date().toISOString().slice(0, 10)}.json"`); return send(res, 200, fullState(req.user.name)); } if (req.method === "PUT" && (p = m(/^\/api\/parts\/([^/]+)$/))) { const id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id"); const b = await readJson(req); const status = STATUSES.has(b.s) ? b.s : "none"; const note = cleanText(b.note, 2000); const old = db.prepare("SELECT status, note FROM parts WHERE id = ?").get(id) || { status: "none", note: "" }; if (old.status === status && old.note === note) return send(res, 200, { ok: true }); db.prepare(`INSERT INTO parts (id, status, note, updated, updated_by) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, note = excluded.note, updated = excluded.updated, updated_by = excluded.updated_by`) .run(id, status, note, Date.now(), req.user.name); if (old.status !== status) logActivity(req.user.name, id, "status", status); if (old.note !== note) logActivity(req.user.name, id, "note", ""); return send(res, 200, { ok: true }); } if (req.method === "POST" && (p = m(/^\/api\/parts\/([^/]+)\/links$/))) { const id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id"); const b = await readJson(req); const u = cleanUrl(b.url); if (!u) return fail(res, 400, "Enter a full web address starting with http:// or https://"); const r = db.prepare("INSERT INTO links (part_id, url, label, created, author) VALUES (?, ?, ?, ?, ?)").run(id, u, cleanText(b.label, 200), Date.now(), req.user.name); logActivity(req.user.name, id, "link_add", cleanText(b.label, 200) || u); return send(res, 201, { id: Number(r.lastInsertRowid) }); } if (req.method === "DELETE" && (p = m(/^\/api\/links\/(\d+)$/))) { const old = db.prepare("SELECT part_id, label, url FROM links WHERE id = ?").get(+p[1]); db.prepare("DELETE FROM links WHERE id = ?").run(+p[1]); if (old) logActivity(req.user.name, old.part_id, "link_del", old.label || old.url); return send(res, 200, { ok: true }); } if (req.method === "POST" && (p = m(/^\/api\/parts\/([^/]+)\/comments$/))) { const id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id"); const b = await readJson(req); const text = cleanText(b.text, 4000).trim(); if (!text) return fail(res, 400, "The comment is empty"); const r = db.prepare("INSERT INTO comments (part_id, text, created, author) VALUES (?, ?, ?, ?)").run(id, text, Date.now(), req.user.name); logActivity(req.user.name, id, "comment_add", text.slice(0, 120)); return send(res, 201, { id: Number(r.lastInsertRowid) }); } if (req.method === "DELETE" && (p = m(/^\/api\/comments\/(\d+)$/))) { const old = db.prepare("SELECT part_id, text FROM comments WHERE id = ?").get(+p[1]); db.prepare("DELETE FROM comments WHERE id = ?").run(+p[1]); if (old) logActivity(req.user.name, old.part_id, "comment_del", old.text.slice(0, 120)); return send(res, 200, { ok: true }); } if (req.method === "PUT" && (p = m(/^\/api\/drawings\/([^/]+)\/pins$/))) { const key = p[1]; if (!KEY_RE.test(key)) return fail(res, 400, "Unknown drawing"); const b = await readJson(req); const pins = b.pins && typeof b.pins === "object" ? b.pins : null; db.prepare(`INSERT INTO drawings (key, pins, updated) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET pins = excluded.pins, updated = excluded.updated`) .run(key, pins ? JSON.stringify(pins) : null, Date.now()); logActivity(req.user.name, "", "markers", key); return send(res, 200, { ok: true }); } if (req.method === "POST" && (p = m(/^\/api\/drawings\/([^/]+)\/image$/))) { const key = p[1]; if (!KEY_RE.test(key)) return fail(res, 400, "Unknown drawing"); const ext = IMG_TYPES[(req.headers["content-type"] || "").split(";")[0].trim()]; if (!ext) return fail(res, 415, "Upload a PNG, JPEG, WebP or GIF image"); const buf = await readBody(req, MAX_UPLOAD); if (!buf.length) return fail(res, 400, "The file is empty"); const name = `${key}.${ext}`; for (const e of Object.values(IMG_TYPES)) { try { fs.unlinkSync(path.join(DATA_DIR, "images", `${key}.${e}`)); } catch {} } fs.writeFileSync(path.join(DATA_DIR, "images", name), buf); db.prepare(`INSERT INTO drawings (key, image, updated) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET image = excluded.image, updated = excluded.updated`).run(key, name, Date.now()); return send(res, 200, { ok: true }); } if (req.method === "POST" && url.pathname === "/api/spending") { const b = await readJson(req); const cents = Math.round(Number(String(b.amount ?? "").replace(",", ".")) * 100); const what = cleanText(b.what, 200).trim(); if (!Number.isFinite(cents) || cents <= 0 || cents > 10000000) return fail(res, 400, "Enter an amount greater than 0"); if (!what) return fail(res, 400, "Say what it was spent on"); const r = db.prepare("INSERT INTO spending (author, cents, what, created) VALUES (?, ?, ?, ?)").run(req.user.name, cents, what, Date.now()); logActivity(req.user.name, "", "spend_add", `${(cents / 100).toFixed(2)} · ${what}`); return send(res, 201, { id: Number(r.lastInsertRowid) }); } if (req.method === "DELETE" && (p = m(/^\/api\/spending\/(\d+)$/))) { const old = db.prepare("SELECT author, cents, what FROM spending WHERE id = ?").get(+p[1]); if (!old) return send(res, 200, { ok: true }); if (old.author !== req.user.name) return fail(res, 403, "You can only remove your own entries"); db.prepare("DELETE FROM spending WHERE id = ?").run(+p[1]); logActivity(req.user.name, "", "spend_del", `${(old.cents / 100).toFixed(2)} · ${old.what}`); return send(res, 200, { ok: true }); } if (req.method === "POST" && url.pathname === "/api/photos") { const b = JSON.parse((await readBody(req, MAX_PHOTO_REQ)).toString("utf8") || "{}"); const scope = String(b.scope || ""); if (scope !== "gallery" && !ID_RE.test(scope)) return fail(res, 400, "Unknown photo target"); const img = dataUrlJpeg(b.image), thumb = dataUrlJpeg(b.thumb); if (!img || !thumb) return fail(res, 415, "Upload a JPEG image"); const r = db.prepare("INSERT INTO photos (scope, caption, created, author) VALUES (?, ?, ?, ?)").run(scope, cleanText(b.caption, 300), Date.now(), req.user.name); const id = Number(r.lastInsertRowid); fs.writeFileSync(path.join(DATA_DIR, "images", `photo-${id}.jpg`), img); fs.writeFileSync(path.join(DATA_DIR, "images", `photo-${id}-t.jpg`), thumb); logActivity(req.user.name, scope === "gallery" ? "" : scope, "photo_add", cleanText(b.caption, 300)); return send(res, 201, { id }); } if (req.method === "PUT" && (p = m(/^\/api\/photos\/(\d+)$/))) { const b = await readJson(req); db.prepare("UPDATE photos SET caption = ? WHERE id = ?").run(cleanText(b.caption, 300), +p[1]); return send(res, 200, { ok: true }); } if (req.method === "DELETE" && (p = m(/^\/api\/photos\/(\d+)$/))) { const old = db.prepare("SELECT scope, caption FROM photos WHERE id = ?").get(+p[1]); db.prepare("DELETE FROM photos WHERE id = ?").run(+p[1]); if (old) logActivity(req.user.name, old.scope === "gallery" ? "" : old.scope, "photo_del", old.caption); for (const f of [`photo-${+p[1]}.jpg`, `photo-${+p[1]}-t.jpg`]) { try { fs.unlinkSync(path.join(DATA_DIR, "images", f)); } catch {} } return send(res, 200, { ok: true }); } return fail(res, 404, "Not found"); } function serveFile(res, file, cache) { fs.stat(file, (err, st) => { if (err || !st.isFile()) return fail(res, 404, "Not found"); const h = { "Content-Type": MIME[path.extname(file).toLowerCase()] || "application/octet-stream", "Content-Length": st.size }; if (cache) h["Cache-Control"] = cache; res.writeHead(200, h); fs.createReadStream(file).pipe(res); }); } const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); if (url.pathname === "/login") { if (req.method === "POST") { const ip = clientIp(req), f = fails.get(ip) || { n: 0, until: 0 }; if (f.until > Date.now()) return send(res, 429, LOGIN_HTML("Too many attempts. Try again in a few minutes."), "text/html; charset=utf-8"); const q = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8")); const u = checkLogin(q.get("user"), q.get("pass") || ""); if (u) { fails.delete(ip); res.writeHead(303, { Location: u.must_change ? "/password" : "/", "Set-Cookie": setCookie(u) }); return res.end(); } f.n++; if (f.n >= 8) { f.until = Date.now() + 600000; f.n = 0; } fails.set(ip, f); return send(res, 401, LOGIN_HTML("Wrong username or password."), "text/html; charset=utf-8"); } return send(res, 200, LOGIN_HTML(""), "text/html; charset=utf-8"); } if (url.pathname === "/logout") { res.writeHead(303, { Location: "/login", "Set-Cookie": `spike_session=; ${COOKIE}; Max-Age=0` }); return res.end(); } const user = authUser(req); if (!user) { if (url.pathname.startsWith("/api/")) return fail(res, 401, "Login required"); res.writeHead(303, { Location: "/login" }); return res.end(); } req.user = user; touch(user.name); if (url.pathname === "/password") { if (req.method === "POST") { const q = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8")); const next = q.get("next") || ""; if (!checkLogin(user.name, q.get("current") || "")) return send(res, 401, PASSWORD_HTML(user, "Current password is wrong."), "text/html; charset=utf-8"); if (next.length < 6) return send(res, 400, PASSWORD_HTML(user, "The new password needs at least 6 characters."), "text/html; charset=utf-8"); if (next !== q.get("again")) return send(res, 400, PASSWORD_HTML(user, "The two new passwords don't match."), "text/html; charset=utf-8"); if (next === "lolo") return send(res, 400, PASSWORD_HTML(user, "Pick something other than the starting password."), "text/html; charset=utf-8"); setPassword(user.name, next, false); const fresh = db.prepare("SELECT * FROM users WHERE name = ?").get(user.name); res.writeHead(303, { Location: "/", "Set-Cookie": setCookie(fresh) }); return res.end(); } return send(res, 200, PASSWORD_HTML(user, ""), "text/html; charset=utf-8"); } if (user.must_change) { if (url.pathname.startsWith("/api/")) return fail(res, 403, "Set a new password first"); res.writeHead(303, { Location: "/password" }); return res.end(); } if (url.pathname.startsWith("/api/")) return await api(req, res, url); if (url.pathname.startsWith("/uploads/")) { const name = path.basename(decodeURIComponent(url.pathname.slice(9))); return serveFile(res, path.join(DATA_DIR, "images", name), name.startsWith("photo-") ? "private, max-age=31536000, immutable" : "private, max-age=0, must-revalidate"); } let rel = decodeURIComponent(url.pathname); if (rel === "/") rel = "/index.html"; const file = path.normalize(path.join(PUBLIC_DIR, rel)); if (!file.startsWith(PUBLIC_DIR + path.sep)) return fail(res, 403, "Forbidden"); return serveFile(res, file); } catch (e) { fail(res, e.code === 413 ? 413 : e.code === 400 ? 400 : 500, e.code === 413 ? "File too large" : e.code === 400 ? "Bad request" : "Server error"); if (!e.code) console.error(e); } }); server.listen(PORT, () => console.log(`Spike Parts Board on http://0.0.0.0:${PORT} (data in ${DATA_DIR})`)); for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { server.close(); db.close(); process.exit(0); });