From 94859fb0d67f0ae5cba2e40f473ddafa61edcc32 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 22:52:59 +0200 Subject: [PATCH] Show management links and a signed-in indicator when logged in Add GET /api/session, an unguarded probe that reports whether the request carries valid credentials. It returns 200 with {"authenticated":bool} and never sends WWW-Authenticate, so the static public pages can check login state without triggering the browser's Basic-auth dialog. To keep it from being a password-guessing oracle that bypasses the /upload limiter, a request with no credentials is answered cheaply as false, while wrong credentials go through the same failed-attempt limiter (429 once drained). static/session.js, loaded by every page template, calls it and, only when authenticated, builds a fixed toolbar: Upload / Manage links, an "Edit this" link on photo pages (via , deep-linking to /manage?edit=, which scrolls to and focuses that row), a signed-in indicator, and a log-out control. Logged-out visitors see nothing and get no dialog. Log out is best-effort: HTTP Basic has no reliable sign-out (the browser caches credentials until the window closes), so it overwrites the cached credential and reloads, which works in some browsers but not all. A dependable logout would need cookie sessions. --- CLAUDE.md | 12 ++++++++ manage.go | 33 +++++++++++++++++++++ server.go | 4 +++ static/session.js | 65 +++++++++++++++++++++++++++++++++++++++++ static/style.css | 24 +++++++++++++++ templates/album.html | 1 + templates/albums.html | 1 + templates/day.html | 1 + templates/grid.html | 1 + templates/manage.html | 17 +++++++++++ templates/photo.html | 3 +- templates/timeline.html | 1 + templates/upload.html | 1 + 13 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 static/session.js diff --git a/CLAUDE.md b/CLAUDE.md index f5dffc3..b82ad0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,18 @@ credentials matching a line in that file; `GET /health` stays open. `$2y$` `$apr1$`, SHA, crypt) are skipped with a warning. Empty `-htpasswd` disables auth entirely (dev default) and logs a warning — `/upload` is then open. +`GET /api/session` is a deliberately **unguarded** probe (`handleSession`): it +reads `r.BasicAuth()`, returns `200 {"authenticated":bool}` and never sends +`WWW-Authenticate`, so a public page can ask without triggering the browser's +login dialog. To stop it becoming a password oracle that sidesteps the limiter, +a request with no credentials is answered cheaply as `false`, while one with +*wrong* credentials runs through the same failed-attempt limiter (429 once +drained). `static/session.js` (loaded by every page template) calls it and, when +authenticated, builds a fixed toolbar with Upload / Manage / "Edit this" +(`/manage?edit=`, from the photo pages' `data-slug`) links and a +signed-in indicator. "Log out" is best-effort only — HTTP Basic has no reliable +sign-out; a dependable one would need cookie sessions. + Verification lives in `auth.go`: unknown users still pay for one bcrypt comparison (against a dummy hash) so timing can't enumerate usernames, and **failed** attempts draw from a token-bucket limiter (`golang.org/x/time/rate`, diff --git a/manage.go b/manage.go index a585fd1..0687bd1 100644 --- a/manage.go +++ b/manage.go @@ -26,6 +26,39 @@ type photoJSON struct { URL string `json:"url"` } +// handleSession reports whether the request carries valid credentials, so the +// UI can reveal management links and a signed-in indicator. It always returns +// 200 and never sends WWW-Authenticate, so calling it from a public page does +// not trigger the browser's Basic-auth dialog. +// +// It must not become a password-probing oracle that sidesteps the /upload +// limiter: a request with no credentials (a logged-out browser) is answered +// cheaply as "not authenticated", but one carrying wrong credentials is run +// through the same failed-attempt limiter and gets 429 once that is drained. +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + + if s.auth == nil { // auth disabled → everything is open + writeJSON(w, map[string]bool{"authenticated": true}) + return + } + + user, pass, ok := r.BasicAuth() + if !ok { + writeJSON(w, map[string]bool{"authenticated": false}) + return + } + if s.auth.check(user, pass) { + writeJSON(w, map[string]bool{"authenticated": true}) + return + } + if !s.authLimit.Allow() { + http.Error(w, "too many attempts", http.StatusTooManyRequests) + return + } + writeJSON(w, map[string]bool{"authenticated": false}) +} + // handleManagePage serves the (auth-gated) edit/delete UI. func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { data, err := assets.ReadFile("templates/manage.html") diff --git a/server.go b/server.go index d2f1eb1..555fcc5 100644 --- a/server.go +++ b/server.go @@ -104,6 +104,10 @@ func (s *Server) Listen() error { mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore)) mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash)) + // Login-state probe — deliberately NOT guarded, so public pages can ask + // without triggering a Basic-auth dialog. It does its own silent check. + mux.HandleFunc("GET /api/session", s.handleSession) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) diff --git a/static/session.js b/static/session.js new file mode 100644 index 0000000..42e936c --- /dev/null +++ b/static/session.js @@ -0,0 +1,65 @@ +// Shows a management toolbar + signed-in indicator on every page when the +// browser holds valid credentials. The public gallery is static and carries no +// markup for this; the bar is built here only after /api/session confirms the +// viewer is authenticated, so logged-out visitors see nothing and get no login +// dialog. +// +// A page may set (the photo pages do) to get an +// "Edit this" link straight to that post in /manage. +(function () { + fetch("/api/session", { headers: { Accept: "application/json" }, cache: "no-store" }) + .then((r) => (r.ok ? r.json() : { authenticated: false })) + .then((s) => { + if (s && s.authenticated) buildBar(); + }) + .catch(() => {}); + + function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + function buildBar() { + document.body.classList.add("authed"); + + const bar = el("div", "session-bar"); + const path = location.pathname.replace(/\/$/, ""); + + const link = (href, label) => { + const a = el("a", null, label); + a.href = href; + bar.appendChild(a); + }; + + if (path !== "/upload") link("/upload", "Upload"); + if (path !== "/manage") link("/manage", "Manage"); + + const slug = document.body.dataset.slug; + if (slug) link("/manage?edit=" + encodeURIComponent(slug), "Edit this"); + + bar.appendChild(el("span", "session-dot", "● signed in")); + + // Best-effort logout. HTTP Basic has no real sign-out — the browser caches + // credentials and clears them reliably only when the window closes — so + // this overwrites the cached credential with a throwaway one and reloads. + // It works in some browsers and not others; a dependable logout would need + // cookie sessions. + const out = el("a", "session-out", "log out"); + out.href = "#"; + out.title = "Best-effort — HTTP Basic auth has no reliable logout"; + out.addEventListener("click", (e) => { + e.preventDefault(); + fetch("/api/session", { + headers: { Authorization: "Basic " + btoa("logout:" + Date.now()) }, + cache: "no-store", + }) + .catch(() => {}) + .finally(() => setTimeout(() => location.reload(), 150)); + }); + bar.appendChild(out); + + document.body.appendChild(bar); + } +})(); diff --git a/static/style.css b/static/style.css index 5b08429..4a40375 100644 --- a/static/style.css +++ b/static/style.css @@ -499,3 +499,27 @@ body { background: var(--bg2); color: var(--fg); } + +/* Session toolbar (session.js), shown on any page when signed in. */ +.session-bar { + position: fixed; + top: 0; + right: 0; + z-index: 60; + display: flex; + align-items: center; + gap: 14px; + padding: 7px 14px; + background: var(--bg1); + border-left: 1px solid var(--bg2); + border-bottom: 1px solid var(--bg2); + font-family: monospace; + font-size: 12px; +} +.session-bar a { + color: var(--aqua, #8ec07c); + text-decoration: none; +} +.session-bar a:hover { text-decoration: underline; } +.session-dot { color: var(--green, #b8bb26); } +.session-out { color: var(--fg4) !important; } diff --git a/templates/album.html b/templates/album.html index 26a3deb..5419a15 100644 --- a/templates/album.html +++ b/templates/album.html @@ -92,5 +92,6 @@ io.observe(btn); })(); + diff --git a/templates/albums.html b/templates/albums.html index 68c926d..a613812 100644 --- a/templates/albums.html +++ b/templates/albums.html @@ -50,5 +50,6 @@
© 2000–{{.Year}} {{.Author}}. All rights reserved.
+ diff --git a/templates/day.html b/templates/day.html index 1052fe2..0672a26 100644 --- a/templates/day.html +++ b/templates/day.html @@ -46,5 +46,6 @@
© 2000–{{.Year}} {{.Author}}. All rights reserved.
+ diff --git a/templates/grid.html b/templates/grid.html index fc7eb57..ea9a0d6 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -103,5 +103,6 @@ io.observe(btn); })(); + diff --git a/templates/manage.html b/templates/manage.html index 7d87826..dc0fb9f 100644 --- a/templates/manage.html +++ b/templates/manage.html @@ -83,6 +83,7 @@ #toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; } #toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; } #empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; } + .row-hl { outline: 2px solid var(--aqua, #8ec07c); outline-offset: 4px; transition: outline-color .4s; } @@ -185,12 +186,27 @@ countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts'); emptyEl.style.display = photos.length ? 'none' : 'block'; refreshBulk(); + maybeFocusEdit(); } catch (err) { countEl.textContent = ''; flash('Could not load posts: ' + err.message, false); } } + // Deep link from a photo page's "Edit this": /manage?edit= scrolls to + // that row, focuses its caption and highlights it briefly. + function maybeFocusEdit() { + const slug = new URLSearchParams(location.search).get('edit'); + if (!slug) return; + const row = [...list.querySelectorAll('.row')].find(r => r.dataset.slug === slug); + if (!row) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const cap = row.querySelector('.f-caption'); + if (cap) cap.focus(); + row.classList.add('row-hl'); + setTimeout(() => row.classList.remove('row-hl'), 2000); + } + async function post(url, body) { const res = await fetch(url, { method: 'POST', body }); if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status)); @@ -345,5 +361,6 @@ load(); + diff --git a/templates/photo.html b/templates/photo.html index de06795..0259415 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -35,7 +35,7 @@ {{if .Older}} {{end}} - +