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:
1 parent
c4aa345914
commit
94859fb0d6
13 files changed
+163
-1
No files matched your search
@@ -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
|
`$apr1$`, SHA, crypt) are skipped with a warning. Empty `-htpasswd` disables
|
||||||
auth entirely (dev default) and logs a warning — `/upload` is then open.
|
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
|
Verification lives in `auth.go`: unknown users still pay for one bcrypt
|
||||||
comparison (against a dummy hash) so timing can't enumerate usernames, and
|
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`,
|
**failed** attempts draw from a token-bucket limiter (`golang.org/x/time/rate`,
|
||||||
|
|||||||
@@ -26,6 +26,39 @@ type photoJSON struct {
|
|||||||
URL string `json:"url"`
|
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.
|
// handleManagePage serves the (auth-gated) edit/delete UI.
|
||||||
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := assets.ReadFile("templates/manage.html")
|
data, err := assets.ReadFile("templates/manage.html")
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ func (s *Server) Listen() error {
|
|||||||
mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore))
|
mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore))
|
||||||
mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash))
|
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) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintln(w, "ok")
|
fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -499,3 +499,27 @@ body {
|
|||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
color: var(--fg);
|
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; }
|
||||||
@@ -92,5 +92,6 @@
|
|||||||
io.observe(btn);
|
io.observe(btn);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -50,5 +50,6 @@
|
|||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -46,5 +46,6 @@
|
|||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -103,5 +103,6 @@
|
|||||||
io.observe(btn);
|
io.observe(btn);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
#toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
|
#toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
|
||||||
#toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
|
#toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
|
||||||
#empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -185,12 +186,27 @@
|
|||||||
countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts');
|
countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts');
|
||||||
emptyEl.style.display = photos.length ? 'none' : 'block';
|
emptyEl.style.display = photos.length ? 'none' : 'block';
|
||||||
refreshBulk();
|
refreshBulk();
|
||||||
|
maybeFocusEdit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
countEl.textContent = '';
|
countEl.textContent = '';
|
||||||
flash('Could not load posts: ' + err.message, false);
|
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) {
|
async function post(url, body) {
|
||||||
const res = await fetch(url, { method: 'POST', body });
|
const res = await fetch(url, { method: 'POST', body });
|
||||||
if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status));
|
if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status));
|
||||||
@@ -345,5 +361,6 @@
|
|||||||
|
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
|
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
|
||||||
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
|
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-slug="{{.Photo.Slug}}">
|
||||||
<div class="photo-page">
|
<div class="photo-page">
|
||||||
<nav class="photo-nav">
|
<nav class="photo-nav">
|
||||||
<a href="/" class="back-btn">← Back</a>
|
<a href="/" class="back-btn">← Back</a>
|
||||||
@@ -73,5 +73,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/photo-nav.js" defer></script>
|
<script src="/static/photo-nav.js" defer></script>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -111,5 +111,6 @@
|
|||||||
io.observe(btn);
|
io.observe(btn);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -291,5 +291,6 @@
|
|||||||
submitBtn.textContent = 'Upload photo';
|
submitBtn.textContent = 'Upload photo';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user