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 <body data-slug>, deep-linking to /manage?edit=<slug>,
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.
This commit is contained in:
puttaalu committed 2026-10-18 22:52:59 +02:00
1 parent c4aa345914
commit 94859fb0d6
13 files changed
+163 -1

No files matched your search

+12
View File
@@ -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=<slug>`, 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`,
+33
View File
@@ -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")
+4
View File
@@ -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")
})
+65
View File
@@ -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 <body data-slug="..."> (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);
}
})();
+24
View File
@@ -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; }
+1
View File
@@ -92,5 +92,6 @@
io.observe(btn);
})();
</script>
<script src="/static/session.js"></script>
</body>
</html>
+1
View File
@@ -50,5 +50,6 @@
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script src="/static/session.js"></script>
</body>
</html>
+1
View File
@@ -46,5 +46,6 @@
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script src="/static/session.js"></script>
</body>
</html>
+1
View File
@@ -103,5 +103,6 @@
io.observe(btn);
})();
</script>
<script src="/static/session.js"></script>
</body>
</html>
+17
View File
@@ -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; }
</style>
</head>
<body>
@@ -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=<slug> 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();
</script>
<script src="/static/session.js"></script>
</body>
</html>
+2 -1
View File
@@ -35,7 +35,7 @@
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
</head>
<body>
<body data-slug="{{.Photo.Slug}}">
<div class="photo-page">
<nav class="photo-nav">
<a href="/" class="back-btn">← Back</a>
@@ -73,5 +73,6 @@
</footer>
<script src="/static/photo-nav.js" defer></script>
<script src="/static/session.js"></script>
</body>
</html>
+1
View File
@@ -111,5 +111,6 @@
io.observe(btn);
})();
</script>
<script src="/static/session.js"></script>
</body>
</html>
+1
View File
@@ -291,5 +291,6 @@
submitBtn.textContent = 'Upload photo';
});
</script>
<script src="/static/session.js"></script>
</body>
</html>