commit 9b9516026b485a574fded0491b4de2c44ea44165 Author: flomi Date: Sat Oct 24 11:31:46 2026 +0200 Spike 50 parts board: photos, offline mode, mobile viewer, login page Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c54f60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd7c9e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine +WORKDIR /app +COPY server.js ./ +COPY public ./public +ENV PORT=8080 DATA_DIR=/data NODE_NO_WARNINGS=1 +VOLUME /data +EXPOSE 8080 +USER node +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d1c35d --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Spike 50 Parts Board (self-hosted) + +Parts tracker for the 2005 Rieju Spike 50: factory drawings with clickable markers, status per part +(Have / Missing / Ordered / Not needed), notes, links and comments. Everything is stored on your server. + +- Backend: one Node file (`server.js`), no npm packages. Uses Node 22's built-in SQLite. +- Data: `data/board.db` (SQLite) and `data/images/` (drawings you upload). + +## Run with Docker (recommended) + +```bash +# copy this folder to the server, then: +cd spike-board +mkdir -p data && sudo chown 1000:1000 data # the container runs as user "node" (uid 1000) +docker compose up -d --build +``` + +Open `http://:8085`. + +To set a password, put one in `BOARD_PASSWORD` in `docker-compose.yml` and run `docker compose up -d` again. +The browser then asks for `BOARD_USER` / `BOARD_PASSWORD`. + +## Run without Docker + +Needs Node 22.13 or newer. + +```bash +cd spike-board +node server.js # http://localhost:8080, data in ./data +PORT=9000 DATA_DIR=/srv/spike node server.js +``` + +## Reaching it from outside your home + +Keep it on your LAN or VPN (Tailscale/WireGuard) if you can. If you put it behind a reverse proxy +(Caddy, Nginx Proxy Manager, Traefik) on the internet, set `BOARD_PASSWORD` and use HTTPS, because +basic auth sends the password with every request. + +Caddy example: + +``` +spike.example.com { + reverse_proxy 127.0.0.1:8085 +} +``` + +## Backup + +Copy the `data/` folder (stop the container first for a guaranteed consistent copy), or click +**Download backup** in the page header for a JSON export of all statuses, notes, links, comments and markers. + +## API + +| Method | Path | Body | +| --- | --- | --- | +| GET | `/api/state` | | +| GET | `/api/export` | (same as state, as a download) | +| PUT | `/api/parts/{group}__{ref}` | `{"s":"have"\|"miss"\|"ord"\|"skip"\|"none","note":"…"}` | +| POST | `/api/parts/{group}__{ref}/links` | `{"url":"https://…","label":"…"}` | +| DELETE | `/api/links/{id}` | | +| POST | `/api/parts/{group}__{ref}/comments` | `{"text":"…"}` | +| DELETE | `/api/comments/{id}` | | +| PUT | `/api/drawings/{group}/pins` | `{"pins":{"12":[[x%,y%],…]}}` | +| POST | `/api/drawings/{group}/image` | raw image bytes, `Content-Type: image/png` (or jpeg/webp/gif) | + +Groups: `chassis`, `controls-steer`, `electrics`, `exhaust-radiator-tank-airfilter`, `motord`, `plastics`, `suspension`, `wheels`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01d3aaf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + spike-board: + build: . + container_name: spike-board + restart: unless-stopped + ports: + - "8095:8080" # change 8085 if that port is taken + volumes: + - ./data:/data # database + uploaded drawings live here; back this folder up + environment: + # Optional login. Leave both empty for no password (fine if it's only reachable on your LAN/VPN). + BOARD_USER: "flo" + BOARD_PASSWORD: "${BOARD_PASSWORD:-}" diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..f37d595 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..aca7708 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/img/chassis.webp b/public/img/chassis.webp new file mode 100644 index 0000000..b720e01 Binary files /dev/null and b/public/img/chassis.webp differ diff --git a/public/img/electrics.webp b/public/img/electrics.webp new file mode 100644 index 0000000..6736d82 Binary files /dev/null and b/public/img/electrics.webp differ diff --git a/public/img/exhaust-radiator-tank-airfilter.webp b/public/img/exhaust-radiator-tank-airfilter.webp new file mode 100644 index 0000000..2ebc02b Binary files /dev/null and b/public/img/exhaust-radiator-tank-airfilter.webp differ diff --git a/public/img/motord.webp b/public/img/motord.webp new file mode 100644 index 0000000..3b1d379 Binary files /dev/null and b/public/img/motord.webp differ diff --git a/public/img/plastics.webp b/public/img/plastics.webp new file mode 100644 index 0000000..e9527d9 Binary files /dev/null and b/public/img/plastics.webp differ diff --git a/public/img/suspension.webp b/public/img/suspension.webp new file mode 100644 index 0000000..4f4bf67 Binary files /dev/null and b/public/img/suspension.webp differ diff --git a/public/img/wheels.webp b/public/img/wheels.webp new file mode 100644 index 0000000..b26d30d Binary files /dev/null and b/public/img/wheels.webp differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6ad686a --- /dev/null +++ b/public/index.html @@ -0,0 +1,705 @@ + + + + + +Spike 50 Parts Board + + + + + + + + + +
+
+
+

Rieju Spike 50 · Parts Board

+
+ Model Spike 50 (RR/E2/4)First reg. 2005 + Engine Minarelli AM6Frame no. …550038Colour Blue +
+
+
+
+
+
Loading saved progress…Download backup
+
+
+ + +
+
+ + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..a692e7e --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1 @@ +{"name":"Spike 50 Parts Board","short_name":"Spike 50","start_url":"/","scope":"/","display":"standalone","background_color":"#12171d","theme_color":"#1d5fc4","icons":[{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..6d340b9 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,33 @@ +// Offline support: app shell + photos cached on the device. Data changes are queued by the page itself. +const V = "spike-v1"; +const SHELL = ["/", "manifest.webmanifest", "icon-192.png", "icon-512.png", + "img/chassis.webp", "img/suspension.webp", "img/wheels.webp", "img/plastics.webp", "img/electrics.webp", + "img/exhaust-radiator-tank-airfilter.webp", "img/motord.webp"]; +const okToCache = (r) => r && r.ok && !r.redirected && r.type === "basic"; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(V).then((c) => Promise.all(SHELL.map((u) => fetch(u, { credentials: "same-origin" }).then((r) => okToCache(r) && c.put(u, r)).catch(() => {})))).then(() => self.skipWaiting())); +}); +self.addEventListener("activate", (e) => { + e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== V).map((k) => caches.delete(k)))).then(() => self.clients.claim())); +}); +self.addEventListener("fetch", (e) => { + const req = e.request, url = new URL(req.url); + if (req.method !== "GET" || url.origin !== location.origin) return; + if (url.pathname.startsWith("/api/") || url.pathname === "/login" || url.pathname === "/logout") return; + if (url.pathname.startsWith("/uploads/photo-") || url.pathname.startsWith("/img/")) { + e.respondWith(caches.match(req).then((hit) => hit || fetch(req).then((r) => { if (okToCache(r)) { const c = r.clone(); caches.open(V).then((ch) => ch.put(req, c)); } return r; }))); + return; + } + // everything else (page, drawings, manifest): network first, fall back to the saved copy + e.respondWith(new Promise((resolve) => { + const key = req.mode === "navigate" ? "/" : req; + const fallback = () => caches.match(key, { ignoreSearch: url.pathname.startsWith("/uploads/") ? false : true }).then((hit) => resolve(hit || Response.error())); + const timer = setTimeout(() => caches.match(key, { ignoreSearch: !url.pathname.startsWith("/uploads/") }).then((hit) => hit && resolve(hit)), 4000); + fetch(req).then((r) => { + clearTimeout(timer); + if (okToCache(r)) { const c = r.clone(); caches.open(V).then((ch) => ch.put(key, c)); } + resolve(r); + }).catch(() => { clearTimeout(timer); fallback(); }); + })); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..a8eff57 --- /dev/null +++ b/server.js @@ -0,0 +1,259 @@ +// 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 USER = process.env.BOARD_USER || ""; +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); +`); + +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 = crypto.createHash("sha256").update(`spike-session:${USER}:${PASS}`).digest(); +const SESSION_MS = 30 * 24 * 3600 * 1000; +const sign = (v) => crypto.createHmac("sha256", SECRET).update(v).digest("base64url"); +const safeEq = (x, y) => { const a = Buffer.from(x), b = Buffer.from(y); return a.length === b.length && crypto.timingSafeEqual(a, b); }; +function makeSession() { const exp = String(Date.now() + SESSION_MS); return `${exp}.${sign(exp)}`; } +function validSession(req) { + const m = /(?:^|;\s*)spike_session=([^;]+)/.exec(req.headers.cookie || ""); + if (!m) return false; + const [exp, sig] = m[1].split("."); + return !!sig && +exp > Date.now() && safeEq(sig, sign(exp)); +} +const LOGIN_HTML = (err) => `Spike Parts Board - Login +

Spike 50 Parts Board

Sign in to continue

+ + +${err ? '
Wrong username or password.
' : ""}
`; +function authorized(req) { + if (!PASS) return true; + if (validSession(req)) return true; + const h = req.headers.authorization || ""; + if (!h.startsWith("Basic ")) return false; + const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(/:(.*)/s); + const a = Buffer.from(`${u}:${p}`), b = Buffer.from(`${USER}:${PASS}`); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} +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() { + const parts = {}, links = {}, comments = {}, drawings = {}, photos = {}; + for (const r of db.prepare("SELECT id, status, note FROM parts").all()) parts[r.id] = { s: r.status, note: r.note }; + for (const r of db.prepare("SELECT id, part_id, url, label, created FROM links ORDER BY created").all()) + (links[r.part_id] ||= []).push({ id: r.id, url: r.url, label: r.label, at: r.created }); + for (const r of db.prepare("SELECT id, part_id, text, created FROM comments ORDER BY created").all()) + (comments[r.part_id] ||= []).push({ id: r.id, text: r.text, at: r.created }); + 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 FROM photos ORDER BY created DESC, id DESC").all()) + (photos[r.scope] ||= []).push({ id: r.id, caption: r.caption, at: r.created, src: `uploads/photo-${r.id}.jpg`, thumb: `uploads/photo-${r.id}-t.jpg` }); + return { parts, links, comments, drawings, photos }; +} + +// ---------- routes ---------- +async function api(req, res, url) { + const m = (re) => url.pathname.match(re); + let p; + if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, fullState()); + + 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()); + } + + 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"; + db.prepare(`INSERT INTO parts (id, status, note, updated) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET status = excluded.status, note = excluded.note, updated = excluded.updated`) + .run(id, status, cleanText(b.note, 2000), Date.now()); + 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) VALUES (?, ?, ?, ?)").run(id, u, cleanText(b.label, 200), Date.now()); + return send(res, 201, { id: Number(r.lastInsertRowid) }); + } + if (req.method === "DELETE" && (p = m(/^\/api\/links\/(\d+)$/))) { + db.prepare("DELETE FROM links WHERE id = ?").run(+p[1]); 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) VALUES (?, ?, ?)").run(id, text, Date.now()); + return send(res, 201, { id: Number(r.lastInsertRowid) }); + } + if (req.method === "DELETE" && (p = m(/^\/api\/comments\/(\d+)$/))) { + db.prepare("DELETE FROM comments WHERE id = ?").run(+p[1]); 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()); + 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/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) VALUES (?, ?, ?)").run(scope, cleanText(b.caption, 300), Date.now()); + 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); + 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+)$/))) { + db.prepare("DELETE FROM photos WHERE id = ?").run(+p[1]); + 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 (PASS && url.pathname === "/login") { + if (req.method === "POST") { + const f = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8")); + if (safeEq(`${f.get("user") || ""}:${f.get("pass") || ""}`, `${USER}:${PASS}`)) { + res.writeHead(303, { Location: "/", "Set-Cookie": `spike_session=${makeSession()}; Path=/; Max-Age=${SESSION_MS / 1000}; HttpOnly; Secure; SameSite=Lax` }); + return res.end(); + } + return send(res, 401, LOGIN_HTML(true), "text/html; charset=utf-8"); + } + return send(res, 200, LOGIN_HTML(false), "text/html; charset=utf-8"); + } + if (PASS && url.pathname === "/logout") { + res.writeHead(303, { Location: "/login", "Set-Cookie": "spike_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" }); + return res.end(); + } + if (!authorized(req)) { + if (url.pathname.startsWith("/api/")) return fail(res, 401, "Login required"); + res.writeHead(303, { Location: "/login" }); 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}${PASS ? ", password on" : ""})`)); +for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { server.close(); db.close(); process.exit(0); });