aboutsummaryrefslogtreecommitdiff
path: root/server.mjs
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-05 15:24:33 +0100
committerdax <me@dax.ist>2026-08-05 15:24:33 +0100
commit03dcc91c96ac45ff005a1c3e14a71a5733037447 (patch)
tree48cbb120853132e299dd79559321265a5a8fdd42 /server.mjs
parentea71584d1fc54d4d6f5799914632328f1327dc08 (diff)
admin panel for walk-graph management; /api/import gated behind admin login
Diffstat (limited to 'server.mjs')
-rw-r--r--server.mjs228
1 files changed, 223 insertions, 5 deletions
diff --git a/server.mjs b/server.mjs
index e3c4c2a..ab5e763 100644
--- a/server.mjs
+++ b/server.mjs
@@ -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)