From 03dcc91c96ac45ff005a1c3e14a71a5733037447 Mon Sep 17 00:00:00 2001 From: dax Date: Wed, 5 Aug 2026 15:24:33 +0100 Subject: admin panel for walk-graph management; /api/import gated behind admin login --- .gitignore | 2 + public/admin.css | 212 ++++++++++++++++++++++++++++++ public/admin.html | 70 ++++++++++ public/admin.js | 385 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/app.js | 14 +- public/index.html | 5 +- server.mjs | 228 +++++++++++++++++++++++++++++++- 7 files changed, 908 insertions(+), 8 deletions(-) create mode 100644 public/admin.css create mode 100644 public/admin.html create mode 100644 public/admin.js diff --git a/.gitignore b/.gitignore index 5e3e4a4..b925081 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules/ *.log .env* staging/ +admin.json +graph.json diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..d698076 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,212 @@ +:root { + --bg: #0e0f13; + --panel: #16181f; + --panel-2: #1d2028; + --ring: #7dd3fc; + --ink: #e6e8ee; + --muted: #8b90a0; + --danger: #fca5a5; + --ok: #86efac; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--ink); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +body { + display: grid; + place-items: start center; + padding: 32px 16px 64px; +} + +#app { width: 100%; max-width: 640px; } + +h1 { + margin: 0 0 6px; + font-size: 30px; + font-weight: 300; + letter-spacing: .12em; +} +h1 .dot { color: var(--ring); } +.sub { color: var(--muted); margin: 0 0 20px; line-height: 1.5; } + +form { + display: grid; + gap: 10px; + margin-bottom: 8px; +} + +input[type="password"], input[type="search"], input[type="text"] { + width: 100%; + padding: 12px 14px; + font-size: 15px; + color: var(--ink); + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 8px; + outline: none; +} +input:focus { border-color: var(--ring); } + +.remember { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--muted); +} + +button { + padding: 11px 18px; + font-size: 14px; + letter-spacing: .08em; + color: var(--ink); + background: transparent; + border: 1px solid rgba(255, 255, 255, .28); + border-radius: 999px; + cursor: pointer; + transition: border-color .15s, background .15s, opacity .15s; +} +button:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } +button:disabled { opacity: .45; cursor: default; } +button.danger { border-color: rgba(252, 165, 165, .5); color: var(--danger); } +button.danger:hover { border-color: var(--danger); background: rgba(252, 165, 165, .08); } + +.err { color: var(--danger); min-height: 20px; font-size: 14px; } +.facets { color: var(--muted); font-size: 13px; margin: 16px 0 0; line-height: 1.7; } + +.top { margin-bottom: 24px; } +.top nav { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-top: 12px; +} +.top nav #logout { margin-left: auto; } +.tab.active { border-color: var(--ring); background: rgba(125, 211, 252, .12); } + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} +.card { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + padding: 14px; + display: grid; + gap: 4px; +} +.card .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; } +.card .v { font-size: 20px; } +.card .v.ok { color: var(--ok); } +.card .v.bad { color: var(--danger); } + +.row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; } + +#tab-graph { display: grid; gap: 14px; } +#gq { padding: 12px 14px; } + +.results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + max-height: 320px; + overflow-y: auto; +} +.result { + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + padding: 0; + text-align: left; + transition: border-color .15s; +} +.result:hover { border-color: var(--ring); } +.result img { width: 100%; height: 74px; object-fit: cover; display: block; } +.result .t { padding: 6px 8px; font-size: 12px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.result .d { padding: 0 8px 6px; font-size: 11px; color: var(--muted); } + +.graph { display: grid; gap: 10px; } +.grow { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + padding: 12px; + display: grid; + gap: 10px; +} +.grow-head { display: flex; gap: 12px; align-items: center; } +.grow-head img { width: 52px; height: 52px; object-fit: cover; border-radius: 6px; } +.grow-head .t { font-weight: 500; } +.grow-head .id { color: var(--muted); font-size: 12px; } + +.dir-row { + display: grid; + grid-template-columns: 34px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px; + background: var(--panel-2); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, .06); +} +.dir-badge { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + background: rgba(125, 211, 252, .14); + color: var(--ring); +} +.dir-dest { display: flex; align-items: center; gap: 10px; min-width: 0; } +.dir-dest img { width: 40px; height: 40px; object-fit: cover; border-radius: 5px; flex: none; } +.dir-dest .t { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dir-dest .meta { font-size: 12px; color: var(--muted); } +.dir-dest .override-badge { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--ok); border: 1px solid rgba(134, 239, 172, .4); border-radius: 999px; padding: 1px 6px; } +.dir-actions { display: flex; gap: 6px; } +.dir-actions button { padding: 6px 12px; font-size: 12px; } + +.picker { + grid-column: 1 / -1; + display: grid; + gap: 8px; +} +.picker .presults { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 6px; + max-height: 220px; + overflow-y: auto; +} + +#toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .15); + color: var(--ink); + padding: 10px 18px; + border-radius: 999px; + font-size: 14px; + opacity: 0; + pointer-events: none; + transition: opacity .2s; + max-width: 90vw; +} +#toast.show { opacity: 1; } diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..c722af6 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,70 @@ + + + + + + + pathways · admin + + + +
+ + + + + +
+
+ + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..fa74635 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,385 @@ +"use strict" + +const $ = (s) => document.querySelector(s) +const KEY = "pathways_admin" + +const views = { setup: $("#view-setup"), login: $("#view-login"), dash: $("#view-dash") } +const toastEl = $("#toast") +let toastTimer = 0 +let currentGraphId = null + +function toast(msg) { + toastEl.textContent = msg + toastEl.classList.add("show") + clearTimeout(toastTimer) + toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600) +} + +function show(view) { + for (const [k, el] of Object.entries(views)) el.hidden = k !== view +} + +function setToken(token, remember) { + const entry = JSON.stringify({ token }) + if (remember) { + localStorage.setItem(KEY, entry) + sessionStorage.removeItem(KEY) + } else { + sessionStorage.setItem(KEY, entry) + localStorage.removeItem(KEY) + } +} + +function getToken() { + for (const store of [sessionStorage, localStorage]) { + try { + const raw = store.getItem(KEY) + if (raw) { const t = JSON.parse(raw).token; if (t) return t } + } catch { /* ignore */ } + } + return null +} + +function clearToken() { + try { localStorage.removeItem(KEY) } catch { /* ignore */ } + try { sessionStorage.removeItem(KEY) } catch { /* ignore */ } +} + +async function api(path, opts = {}) { + const headers = { ...(opts.headers || {}) } + const token = getToken() + if (token) headers.Authorization = "Bearer " + token + const res = await fetch(path, { ...opts, headers }) + let data = {} + try { data = await res.json() } catch { /* ignore */ } + if (res.status === 401) { + clearToken() + show("login") + if (data.error) toast(data.error) + throw new Error(data.error || "unauthorized") + } + if (!res.ok) throw new Error(data.error || `request failed (${res.status})`) + return data +} + +function errInto(el, err) { + el.textContent = err && err.message ? err.message : "" +} + +async function loadStatus() { + const s = await api("/api/admin/status") + $("#st-total").textContent = s.stateLoaded ? String(s.total) : "unavailable" + $("#st-built").textContent = s.builtAt ? new Date(s.builtAt).toLocaleString() : "–" + $("#st-age").textContent = s.builtAgeMs != null ? fmtAge(s.builtAgeMs) : "–" + $("#st-overrides").textContent = String(s.overrides) + const dbEl = $("#st-db") + dbEl.textContent = s.db === "ok" ? "ok" : "error" + dbEl.className = "v " + (s.db === "ok" ? "ok" : "bad") + $("#st-up").textContent = fmtUptime(s.uptimeS) + const f = s.facetCounts || {} + const parts = [] + for (const type of ["label", "keyword", "camera", "lens"]) { + if (f[type]) parts.push(`${type}s: ${f[type]}`) + } + $("#st-facets").textContent = parts.length ? "facet paths: " + parts.join(" · ") : "no facet paths yet" +} + +function fmtAge(ms) { + const m = Math.floor(ms / 60000) + if (m < 1) return "just now" + if (m < 60) return `${m} min ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h} h ${m % 60} m ago` + return `${Math.floor(h / 24)} d ${h % 24} h ago` +} + +function fmtUptime(s) { + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + return h ? `${h} h ${m} m` : `${m} m` +} + +let debounceTimer = 0 +function debounce(fn, ms) { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(fn, ms) +} + +async function searchPhotos(q, limit = 24) { + const data = await api(`/api/admin/photos?q=${encodeURIComponent(q)}&limit=${limit}`) + return data.photos +} + +function photoBox(p) { + const el = document.createElement("button") + el.type = "button" + el.className = "result" + const img = document.createElement("img") + img.src = p.thumb + img.alt = "" + img.loading = "lazy" + const t = document.createElement("div") + t.className = "t" + t.textContent = p.title || `photo ${p.id}` + const d = document.createElement("div") + d.className = "d" + d.textContent = p.date || `#${p.id}` + el.append(img, t, d) + return el +} + +async function runGraphSearch() { + const q = $("#gq").value.trim() + const results = $("#g-results") + if (!q) { results.hidden = true; return } + try { + const photos = await searchPhotos(q) + results.hidden = false + results.replaceChildren() + for (const p of photos) { + const box = photoBox(p) + box.addEventListener("click", () => { loadGraph(p.id); currentGraphId = p.id }) + results.appendChild(box) + } + if (!photos.length) { + const none = document.createElement("div") + none.className = "t" + none.textContent = "no matches" + results.appendChild(none) + } + } catch (err) { + results.hidden = true + toast(err.message) + } +} + +function pickerRow(row, dir, id) { + const box = document.createElement("div") + box.className = "picker" + box.hidden = true + const input = document.createElement("input") + input.type = "search" + input.placeholder = "search destination…" + const presults = document.createElement("div") + presults.className = "presults" + box.append(input, presults) + let timer = 0 + input.addEventListener("input", () => { + clearTimeout(timer) + timer = setTimeout(async () => { + const q = input.value.trim() + if (!q) { presults.replaceChildren(); return } + try { + const photos = await searchPhotos(q, 12) + presults.replaceChildren() + for (const p of photos) { + const opt = photoBox(p) + opt.addEventListener("click", async () => { + try { + await api("/api/admin/graph", { method: "PUT", body: JSON.stringify({ id, dir, target: p.id }), headers: { "Content-Type": "application/json" } }) + toast(`set ${dir} → ${p.title || p.id}`) + box.hidden = true + loadGraph(id) + } catch (e2) { toast(e2.message) } + }) + presults.appendChild(opt) + } + } catch (e2) { toast(e2.message) } + }, 220) + }) + row.appendChild(box) +} + +function renderGraph(d) { + const wrap = $("#g-graph") + wrap.hidden = false + wrap.replaceChildren() + const head = document.createElement("div") + head.className = "grow-head" + const info = d.dirs[0]?.current || d.dirs[0]?.default + if (info && info.thumb) { + const img = document.createElement("img") + img.src = info.thumb + img.alt = "" + head.appendChild(img) + } + const t = document.createElement("div") + const title = document.createElement("div") + title.className = "t" + title.textContent = info?.title || `photo ${d.id}` + const idLine = document.createElement("div") + idLine.className = "id" + idLine.textContent = `#${d.id}` + t.append(title, idLine) + head.appendChild(t) + wrap.appendChild(head) + + for (const dir of d.dirs) { + const row = document.createElement("div") + row.className = "dir-row" + + const badge = document.createElement("div") + badge.className = "dir-badge" + badge.textContent = dir.dir + + const dest = document.createElement("div") + dest.className = "dir-dest" + if (dir.current) { + const img = document.createElement("img") + img.src = dir.current.thumb + img.alt = "" + const meta = document.createElement("div") + const t2 = document.createElement("div") + t2.className = "t" + t2.textContent = dir.current.title || `photo ${dir.current.id}` + const m2 = document.createElement("div") + m2.className = "meta" + m2.textContent = `${dir.current.date || "#" + dir.current.id} · ${dir.path.label}: ${dir.path.name}` + meta.append(t2, m2) + dest.append(img, meta) + if (dir.overridden) { + const b = document.createElement("span") + b.className = "override-badge" + b.textContent = "override" + meta.appendChild(b) + } + } else { + dest.textContent = "no destination" + dest.style.color = "var(--muted)" + } + + const actions = document.createElement("div") + actions.className = "dir-actions" + const change = document.createElement("button") + change.textContent = dir.overridden ? "change" : "set" + change.addEventListener("click", () => { + const picker = row.querySelector(".picker") + if (picker) { picker.hidden = !picker.hidden; return } + const p = document.createElement("div") + p.className = "picker" + pickerRow(p, dir.dir, d.id) + row.appendChild(p) + }) + const reset = document.createElement("button") + reset.textContent = "default" + reset.disabled = !dir.overridden + reset.addEventListener("click", async () => { + try { + await api("/api/admin/graph", { method: "PUT", body: JSON.stringify({ id: d.id, dir: dir.dir, target: null }), headers: { "Content-Type": "application/json" } }) + toast(`reset ${dir.dir} to default`) + loadGraph(d.id) + } catch (err) { toast(err.message) } + }) + actions.append(change, reset) + + row.append(badge, dest, actions) + pickerRow(row, dir.dir, d.id) + wrap.appendChild(row) + } +} + +async function loadGraph(id) { + try { + const d = await api(`/api/admin/graph/${id}`) + currentGraphId = id + renderGraph(d) + $("#g-graph").scrollIntoView({ behavior: "smooth", block: "nearest" }) + } catch (err) { + toast(err.message) + } +} + +async function enterDash() { + show("dash") + try { + await loadStatus() + } catch (err) { if (views.dash.hidden) return; } +} + +async function init() { + document.querySelectorAll(".tab").forEach((b) => { + b.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active")) + b.classList.add("active") + const tab = b.dataset.tab + $("#tab-status").hidden = tab !== "status" + $("#tab-graph").hidden = tab !== "graph" + }) + }) + + $("#setup-form").addEventListener("submit", async (e) => { + e.preventDefault() + const err = $("#setup-err") + const pw = $("#setup-pw").value + const pw2 = $("#setup-pw2").value + err.textContent = "" + if (pw.length < 8) { err.textContent = "password must be at least 8 characters"; return } + if (pw !== pw2) { err.textContent = "passwords don't match"; return } + try { + await api("/api/admin/setup", { method: "POST", body: JSON.stringify({ password: pw }), headers: { "Content-Type": "application/json" } }) + toast("password set — sign in") + $("#setup-pw").value = "" + $("#setup-pw2").value = "" + show("login") + } catch (err2) { errInto(err, err2) } + }) + + $("#login-form").addEventListener("submit", async (e) => { + e.preventDefault() + const err = $("#login-err") + err.textContent = "" + try { + const data = await api("/api/admin/login", { method: "POST", body: JSON.stringify({ password: $("#login-pw").value, remember: $("#login-remember").checked }), headers: { "Content-Type": "application/json" } }) + setToken(data.token, !!data.remember) + $("#login-pw").value = "" + await enterDash() + } catch (err2) { errInto(err, err2) } + }) + + $("#logout").addEventListener("click", async () => { + try { await api("/api/admin/logout", { method: "POST" }) } catch { /* ignore */ } + clearToken() + show("login") + }) + + $("#rebuild").addEventListener("click", async () => { + const btn = $("#rebuild") + btn.disabled = true + try { + const r = await api("/api/admin/refresh", { method: "POST" }) + toast(`rebuilt — ${r.total} photos`) + await loadStatus() + } catch (err) { toast(err.message) } + btn.disabled = false + }) + + $("#reset-graph").addEventListener("click", async () => { + if (!confirm("Clear all walk-graph overrides?")) return + try { + await api("/api/admin/graph/reset", { method: "POST" }) + toast("all overrides cleared") + await loadStatus() + if (currentGraphId != null) loadGraph(currentGraphId) + } catch (err) { toast(err.message) } + }) + + $("#gq").addEventListener("input", () => debounce(runGraphSearch, 220)) + + const token = getToken() + if (token) { + try { + await loadStatus() + show("dash") + return + } catch { /* 401 handled → login view */ } + } + try { + const s = await api("/api/admin/setup-state") + show(s.configured ? "login" : "setup") + } catch (err) { + show("login") + toast(err.message) + } +} + +init() diff --git a/public/app.js b/public/app.js index 0959509..311faa9 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,16 @@ const progressText = $("#progress-text") const splash = $("#splash") const startBtn = $("#start") +function adminToken() { + for (const store of [localStorage, sessionStorage]) { + try { + const raw = store.getItem("pathways_admin") + if (raw) { const t = JSON.parse(raw).token; if (t) return t } + } catch { /* ignore */ } + } + return null +} + addEventListener("error", (e) => { toast(`error: ${e.message || "script failed"}`, true) }) @@ -306,7 +316,7 @@ function choosePlacement(id, dir) { scored.sort((a, b) => a.cost - b.cost) const best = scored[0] const next = ex.next && !rectOf.has(ex.next.id) ? scored.find((s) => s.id === ex.next.id) : null - const chosen = next && next.cost - best.cost <= FIT_TOLERANCE ? next : best + const chosen = (ex.forced && next) || (next && next.cost - best.cost <= FIT_TOLERANCE) ? next : best const out = { id: chosen.id, info: chosen.info, rect: chosen.rect } placementCache.set(key, out) return out @@ -843,6 +853,8 @@ function uploadWithProgress(form, onPct, onUploaded) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("POST", "/api/import") + const tok = adminToken() + if (tok) xhr.setRequestHeader("Authorization", "Bearer " + tok) xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) onPct(Math.min(100, Math.round((e.loaded / e.total) * 100))) }) diff --git a/public/index.html b/public/index.html index 24503ba..8b1f7d4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ pathways - +
@@ -20,6 +20,7 @@ import photos +

sign in to manage the walk graph & uploads: admin

@@ -34,6 +35,6 @@
- + 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) -- cgit v1.3.1