// 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); });