diff options
| author | dax <me@dax.ist> | 2026-08-05 15:24:33 +0100 |
|---|---|---|
| committer | dax <me@dax.ist> | 2026-08-05 15:24:33 +0100 |
| commit | 03dcc91c96ac45ff005a1c3e14a71a5733037447 (patch) | |
| tree | 48cbb120853132e299dd79559321265a5a8fdd42 /server.mjs | |
| parent | ea71584d1fc54d4d6f5799914632328f1327dc08 (diff) | |
admin panel for walk-graph management; /api/import gated behind admin login
Diffstat (limited to 'server.mjs')
| -rw-r--r-- | server.mjs | 228 |
1 files changed, 223 insertions, 5 deletions
@@ -1,15 +1,16 @@ import http from "node:http" -import { open, mkdir, rm, rename } from "node:fs/promises" +import { open, mkdir, rm, rename, readFile, writeFile } from "node:fs/promises" import { createReadStream, createWriteStream, existsSync } from "node:fs" import { extname, join, normalize } from "node:path" import { execFile } from "node:child_process" -import { randomUUID } from "node:crypto" +import { randomUUID, randomBytes, scrypt, timingSafeEqual } from "node:crypto" import { promisify } from "node:util" import { once } from "node:events" import { networkInterfaces, hostname } from "node:os" import mysql from "mysql2/promise" const execFileAsync = promisify(execFile) +const scryptAsync = promisify(scrypt) const DB = { host: process.env.DB_HOST || "127.0.0.1", @@ -22,6 +23,8 @@ const DB = { const PORT = Number(process.env.PORT || 3100) const PUBLIC = join(process.cwd(), "public") +const ADMIN_FILE = join(process.cwd(), "admin.json") +const GRAPH_FILE = join(process.cwd(), "graph.json") const MIN_PATH = 3 const STAGING_ROOT = process.env.STAGING_ROOT || join(process.cwd(), "staging") @@ -66,6 +69,66 @@ const thumb = (hash, size = THUMB_SIZE) => { let state = null +const SESSIONS = new Map() +let loginFails = 0 +let loginLockUntil = 0 +let lastLoginAt = 0 + +async function readJson(path) { + try { return JSON.parse(await readFile(path, "utf8")) } catch { return null } +} + +async function writeJson(path, data) { + const tmp = path + ".tmp" + await writeFile(tmp, JSON.stringify(data, null, 2)) + await rename(tmp, path) +} + +async function loadAdmin() { + return await readJson(ADMIN_FILE) +} + +async function loadGraph() { + return (await readJson(GRAPH_FILE)) || {} +} + +async function hashPassword(password, saltHex) { + const buf = await scryptAsync(password, Buffer.from(saltHex, "hex"), 64) + return buf.toString("hex") +} + +async function verifyPassword(password, admin) { + const want = Buffer.from(admin.hash, "hex") + const got = Buffer.from(await hashPassword(password, admin.salt), "hex") + return got.length === want.length && timingSafeEqual(got, want) +} + +async function authToken(req) { + const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || "") + if (!m) return null + const exp = SESSIONS.get(m[1]) + if (!exp) return null + if (Date.now() > exp) { SESSIONS.delete(m[1]); return null } + return m[1] +} + +async function readBody(req, limit) { + const chunks = [] + let total = 0 + for await (const chunk of req) { + total += chunk.length + if (total > limit) throw Object.assign(new Error("request body too large"), { status: 413 }) + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) +} + +function photoSummary(state, id) { + const p = state.byId.get(id) + if (!p) return null + return { id: p.id, title: p.title, thumb: p.thumb, date: p.date } +} + async function load() { const [photos] = await pool.query( `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day, @@ -189,7 +252,27 @@ async function load() { exits.set(pid, list) } - return { builtAt: new Date().toISOString(), total: photoOrder.length, photos: Object.fromEntries(byId), facets, photoOrder, byId, exits } + const defaultNext = new Map() + for (const [pid, list] of exits) { + defaultNext.set(pid, new Map(list.map((x) => [x.dir, x.nextId]))) + } + const overridden = new Map() + for (const [pidStr, dirs] of Object.entries(await loadGraph())) { + const pid = Number(pidStr) + const list = exits.get(pid) + if (!list) continue + for (const [dir, target] of Object.entries(dirs)) { + if (target == null) continue + if (!byId.has(Number(target))) continue + const entry = list.find((x) => x.dir === dir) + if (!entry) continue + entry.nextId = Number(target) + if (!overridden.has(pid)) overridden.set(pid, new Set()) + overridden.get(pid).add(dir) + } + } + + return { builtAt: new Date().toISOString(), total: photoOrder.length, photos: Object.fromEntries(byId), facets, photoOrder, byId, exits, defaultNext, overridden } } async function refresh() { @@ -483,16 +566,151 @@ const server = http.createServer(async (req, res) => { } return json(res, 200, { photo: state.byId.get(id), - exits: e.map((x) => ({ dir: x.dir, path: x.path, next: state.byId.get(x.nextId), walk: x.walk })), + exits: e.map((x) => ({ dir: x.dir, path: x.path, next: state.byId.get(x.nextId), walk: x.walk, forced: state.overridden.get(id)?.has(x.dir) || false })), photos, }) } - if (path === "/api/import") return await handleImport(req, res) + if (path === "/api/admin/setup-state") { + return json(res, 200, { configured: !!(await loadAdmin()) }) + } + if (path === "/api/admin/setup") { + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }) + if (await loadAdmin()) return json(res, 409, { error: "admin already configured" }) + const body = await readBody(req, 1024) + const password = String(body?.password || "") + if (password.length < 8) return json(res, 400, { error: "password must be at least 8 characters" }) + const salt = randomBytes(16).toString("hex") + const hash = await hashPassword(password, salt) + await writeJson(ADMIN_FILE, { salt, hash, createdAt: new Date().toISOString() }) + return json(res, 200, { ok: true }) + } + if (path === "/api/admin/login") { + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }) + const now = Date.now() + if (now < loginLockUntil) return json(res, 429, { error: "too many attempts, try again later" }) + if (now - lastLoginAt < 500) return json(res, 429, { error: "slow down" }) + lastLoginAt = now + const admin = await loadAdmin() + if (!admin) return json(res, 400, { error: "no admin password configured yet" }) + const body = await readBody(req, 1024) + const password = String(body?.password || "") + if (!(await verifyPassword(password, admin))) { + loginFails++ + if (loginFails >= 8) { loginFails = 0; loginLockUntil = Date.now() + 60_000 } + return json(res, 401, { error: "wrong password" }) + } + loginFails = 0 + const remember = !!body?.remember + const ttl = remember ? 180 * 24 * 3600 * 1000 : 12 * 3600 * 1000 + const token = randomBytes(32).toString("hex") + const expiresAt = Date.now() + ttl + SESSIONS.set(token, expiresAt) + return json(res, 200, { token, expiresAt, remember }) + } + if (path === "/api/admin/logout") { + const token = await authToken(req) + if (token) SESSIONS.delete(token) + return json(res, 200, { ok: true }) + } + + const adminAuthed = (await authToken(req)) !== null + if (path.startsWith("/api/admin/")) { + if (!adminAuthed) return json(res, 401, { error: "unauthorized" }) + if (!state) await refresh() + } + + if (path === "/api/admin/status") { + let db = "ok" + try { await pool.query("SELECT 1") } catch (err) { db = "error: " + err.message } + const facetCounts = {} + if (state) for (const f of state.facets) facetCounts[f.type] = (facetCounts[f.type] || 0) + f.count + let overrides = 0 + if (state) for (const dirs of state.overridden.values()) overrides += dirs.size + return json(res, 200, { + ok: true, + stateLoaded: !!state, + total: state?.total ?? 0, + builtAt: state?.builtAt ?? null, + builtAgeMs: state ? Date.now() - new Date(state.builtAt).getTime() : null, + facetCounts, + overrides, + db, + uptimeS: Math.round(process.uptime()), + }) + } + if (path === "/api/admin/refresh") { + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }) + try { state = await load() } catch (err) { return json(res, 500, { error: "rebuild failed: " + err.message }) } + return json(res, 200, { ok: true, total: state.total, builtAt: state.builtAt }) + } + if (path === "/api/admin/photos") { + const q = (url.searchParams.get("q") || "").trim().toLowerCase() + const limit = Math.min(100, Number(url.searchParams.get("limit") || 30)) + let list = Array.from(state.byId.values()) + if (q) { + const n = Number(q) + list = list.filter((p) => (n && p.id === n) || (p.title || "").toLowerCase().includes(q)) + } + list.sort((a, b) => (a.title || "").localeCompare(b.title || "")) + return json(res, 200, { photos: list.slice(0, limit).map((p) => ({ id: p.id, title: p.title, thumb: p.thumb, date: p.date })) }) + } + if (path === "/api/admin/graph/reset") { + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }) + await writeJson(GRAPH_FILE, {}) + state = await load() + return json(res, 200, { ok: true }) + } + if (path === "/api/admin/graph") { + if (req.method !== "PUT") return json(res, 405, { error: "method not allowed" }) + const body = await readBody(req, 16 * 1024) + const id = Number(body?.id) + const dir = String(body?.dir || "") + const target = body?.target == null ? null : Number(body.target) + if (!state.byId.has(id)) return json(res, 400, { error: "photo not found" }) + if (!["N", "E", "S", "W"].includes(dir)) return json(res, 400, { error: "invalid direction" }) + if (target != null && !state.byId.has(target)) return json(res, 400, { error: "target photo not found" }) + const graph = await loadGraph() + const key = String(id) + if (target == null) { + if (graph[key]) delete graph[key][dir] + if (graph[key] && Object.keys(graph[key]).length === 0) delete graph[key] + } else { + graph[key] = graph[key] || {} + graph[key][dir] = target + } + await writeJson(GRAPH_FILE, graph) + state = await load() + return json(res, 200, { ok: true }) + } + const graphMatch = path.match(/^\/api\/admin\/graph\/(\d+)$/) + if (graphMatch) { + const id = Number(graphMatch[1]) + const list = state.exits.get(id) + if (!list) return json(res, 404, { error: "photo not found" }) + const dirs = list.map((x) => ({ + dir: x.dir, + path: x.path, + current: photoSummary(state, x.nextId), + default: photoSummary(state, state.defaultNext.get(id)?.get(x.dir)), + overridden: state.overridden.get(id)?.has(x.dir) || false, + })) + return json(res, 200, { id, dirs }) + } + + if (path === "/api/import") { + const admin = await loadAdmin() + if (!admin) return json(res, 401, { error: "no admin password configured; visit /admin first" }) + if (!adminAuthed) return json(res, 401, { error: "sign in as admin to upload (visit /admin)" }) + return await handleImport(req, res) + } if (path === "/api/health") return json(res, 200, { ok: true, direct: DIRECT }) if (path === "/" || path === "") { return serveFile(res, join(PUBLIC, "index.html")) } + if (path === "/admin" || path === "/admin/") { + return serveFile(res, join(PUBLIC, "admin.html")) + } const safe = normalize(path).replace(/^(\.\.[/\\])+/, "") const file = join(PUBLIC, safe) if (file.startsWith(PUBLIC)) return serveFile(res, file) |
