Add view tokens for non-public gallery images and per-mode gate pages.

<img> tags cannot send Authorization headers, so non-public gallery photos
were invisible even to authorized viewers. The server now mints short-lived
HMAC view tokens (gallery-scoped, hour-bucketed) and embeds them in every
file URL for non-public galleries. Access denials return distinct 403
messages per visibility mode, and the frontend renders a matching gate page
(private, link-only, ticket-holders, login prompt) with an inline login
modal so visitors never leave the gallery page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-26 06:48:05 +00:00
co-authored by Claude Opus 4.6
parent 93476ac72a
commit 617c884012
9 changed files with 453 additions and 64 deletions
+66 -9
View File
@@ -339,12 +339,13 @@ func TestAccessMatrix(t *testing.T) {
return e.request(t, "GET", path, bearer, nil).Code
}
// private
// private: 403 with a distinct message so the frontend can show its
// gate page (revealing existence is accepted for this community site)
priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"})
for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member),
"with-token": get(priv.Slug, priv.ShareToken, "")} {
if code != 404 {
t.Errorf("private/%s: want 404, got %d", name, code)
if code != 403 {
t.Errorf("private/%s: want 403, got %d", name, code)
}
}
if got := get(priv.Slug, "", admin); got != 200 {
@@ -353,11 +354,11 @@ func TestAccessMatrix(t *testing.T) {
// link
link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"})
if got := get(link.Slug, "", ""); got != 404 {
t.Errorf("link/anon: want 404, got %d", got)
if got := get(link.Slug, "", ""); got != 403 {
t.Errorf("link/anon: want 403, got %d", got)
}
if got := get(link.Slug, "wrong-token", ""); got != 404 {
t.Errorf("link/bad-token: want 404, got %d", got)
if got := get(link.Slug, "wrong-token", ""); got != 403 {
t.Errorf("link/bad-token: want 403, got %d", got)
}
if got := get(link.Slug, link.ShareToken, ""); got != 200 {
t.Errorf("link/token: want 200, got %d", got)
@@ -435,8 +436,64 @@ func TestReorderAndVisibilityUpdate(t *testing.T) {
map[string]any{"visibility": "link"}); w.Code != 200 {
t.Fatalf("set link: %d %s", w.Code, w.Body.String())
}
if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 404 {
t.Fatalf("after switch to link, anon: want 404, got %d", w.Code)
if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 403 {
t.Fatalf("after switch to link, anon: want 403, got %d", w.Code)
}
}
// TestViewTokens covers the <img>-tag reality: image requests cannot carry
// an Authorization header, so authorized gallery responses embed a
// short-lived gallery-scoped token in file URLs that the file handler
// accepts anonymously.
func TestViewTokens(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
// The admin detail response must carry tokenized image URLs.
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
thumbURL := detail.Photos[0].URLs.Thumb
if !strings.Contains(thumbURL, "?token=v1.") {
t.Fatalf("private gallery photo URL should carry a view token: %s", thumbURL)
}
// That URL works with NO Authorization header, exactly like an <img> tag.
if w := e.request(t, "GET", thumbURL, "", nil); w.Code != 200 {
t.Fatalf("anon fetch with view token: want 200, got %d %s", w.Code, w.Body.String())
}
// Without the token (or with a forged one) the same file is denied.
bare := strings.SplitN(thumbURL, "?", 2)[0]
if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
t.Fatalf("anon fetch without token: want 403, got %d", w.Code)
}
if w := e.request(t, "GET", bare+"?token=v1.9999999999.forged", "", nil); w.Code != 403 {
t.Fatalf("forged token: want 403, got %d", w.Code)
}
// A view token for one gallery must not open another gallery's files.
other := createGallery(t, e, admin, map[string]any{"title": "Otra Privada", "visibility": "private"})
op := uploadPhoto(t, e, admin, other.ID, testJPEG(t))
if processQueue(t, e, op.ID).Status != "ready" {
t.Fatal("processing failed")
}
viewToken := strings.SplitN(thumbURL, "?token=", 2)[1]
if w := e.request(t, "GET", "/api/photos/files/"+op.ID+"/thumb?token="+viewToken, "", nil); w.Code != 403 {
t.Fatalf("cross-gallery token reuse: want 403, got %d", w.Code)
}
// Public galleries keep clean URLs (no token needed).
pub := createGallery(t, e, admin, map[string]any{"title": "Publica Limpia", "visibility": "public"})
pp := uploadPhoto(t, e, admin, pub.ID, testJPEG(t))
if processQueue(t, e, pp.ID).Status != "ready" {
t.Fatal("processing failed")
}
pubDetail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+pub.Slug, "", nil))
if strings.Contains(pubDetail.Photos[0].URLs.Thumb, "token=") {
t.Fatalf("public photo URL should be clean: %s", pubDetail.Photos[0].URLs.Thumb)
}
}