Spike 50 parts board: photos, offline mode, mobile viewer, login page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
data/
|
||||
.env
|
||||
9
Dockerfile
Normal file
@@ -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"]
|
||||
66
README.md
Normal file
@@ -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://<server-ip>: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`.
|
||||
13
docker-compose.yml
Normal file
@@ -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:-}"
|
||||
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/img/chassis.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/img/electrics.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/img/exhaust-radiator-tank-airfilter.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
public/img/motord.webp
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/img/plastics.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/img/suspension.webp
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/img/wheels.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
705
public/index.html
Normal file
1
public/manifest.webmanifest
Normal file
@@ -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"}]}
|
||||
33
public/sw.js
Normal file
@@ -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(); });
|
||||
}));
|
||||
});
|
||||
259
server.js
Normal file
@@ -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) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Spike Parts Board - Login</title><style>
|
||||
:root{--bg:#eef1f4;--panel:#fff;--ink:#18212c;--muted:#5d6977;--line:#d5dbe2;--accent:#1d5fc4;--accent-ink:#fff;--err:#b3261e}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#12171d;--panel:#1a212a;--ink:#e6ebf0;--muted:#98a4b2;--line:#2c3642;--accent:#5b97ef;--accent-ink:#0d1520;--err:#f2958e}}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:16px;background:var(--bg);color:var(--ink);font:16px/1.4 system-ui,sans-serif}
|
||||
form{width:100%;max-width:360px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:28px 24px;box-shadow:0 8px 30px rgba(0,0,0,.08)}
|
||||
h1{margin:0 0 4px;font-size:22px}p{margin:0 0 20px;color:var(--muted);font-size:14px}
|
||||
label{display:block;font-size:13px;color:var(--muted);margin:14px 0 6px}
|
||||
input{width:100%;padding:11px 12px;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:8px}
|
||||
input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||
button{width:100%;margin-top:22px;padding:12px;font:inherit;font-weight:600;color:var(--accent-ink);background:var(--accent);border:0;border-radius:8px;cursor:pointer}
|
||||
.err{margin-top:14px;color:var(--err);font-size:14px}</style></head><body>
|
||||
<form method="post" action="/login"><h1>Spike 50 Parts Board</h1><p>Sign in to continue</p>
|
||||
<label for="u">Username</label><input id="u" name="user" autocomplete="username" autofocus required>
|
||||
<label for="p">Password</label><input id="p" name="pass" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Sign in</button>${err ? '<div class="err">Wrong username or password.</div>' : ""}</form></body></html>`;
|
||||
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); });
|
||||