386 lines
25 KiB
JavaScript
386 lines
25 KiB
JavaScript
// 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) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</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}
|
|
a{color:var(--accent);font-size:14px}.err{margin-top:14px;color:var(--err);font-size:14px}</style></head><body>${inner}</body></html>`;
|
|
const LOGIN_HTML = (err) => PAGE("Spike Parts Board - Login", `<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" autocapitalize="none" 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">${err}</div>` : ""}</form>`);
|
|
const PASSWORD_HTML = (u, err) => PAGE("Spike Parts Board - Password", `<form method="post" action="/password"><h1>${u.must_change ? "Choose a new password" : "Change password"}</h1>
|
|
<p>${u.must_change ? `Welcome ${u.name}! Before you continue, set your own password.` : `Signed in as ${u.name}.`}</p>
|
|
<label for="c">Current password</label><input id="c" name="current" type="password" autocomplete="current-password" required>
|
|
<label for="n">New password (at least 6 characters)</label><input id="n" name="next" type="password" autocomplete="new-password" minlength="6" required>
|
|
<label for="r">New password again</label><input id="r" name="again" type="password" autocomplete="new-password" minlength="6" required>
|
|
<button type="submit">Save password</button>${err ? `<div class="err">${err}</div>` : ""}${u.must_change ? "" : '<p style="margin:16px 0 0"><a href="/">Back to the board</a></p>'}</form>`);
|
|
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); });
|