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
+13 -7
View File
@@ -7,11 +7,10 @@ import (
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// accessDenial describes why a viewer may not see a gallery. Non-public
// galleries return 404 (not 403) so their existence is not probeable — the
// exception is ticket mode, whose 401/403 lets the frontend prompt login
// or explain the attendee requirement (its existence is already public via
// the event).
// accessDenial describes why a viewer may not see a gallery. Each mode has
// a distinct message so the frontend can show a matching gate page (log in,
// ask for the share link, buy a ticket). The messages are part of the API
// contract with GalleryClient.tsx — change both together.
type accessDenial struct {
status int
msg string
@@ -24,6 +23,13 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
if user != nil && user.IsAdmin() {
return nil
}
// A server-minted view token grants access to this one gallery until it
// expires; it is only ever issued after a successful authorize, and it
// is what lets <img> requests (which cannot carry a Bearer header)
// through for non-public galleries.
if token != "" && verifyViewToken([]byte(s.cfg.JWTSecret), g.ID, token) {
return nil
}
switch g.Visibility {
case store.VisibilityPublic:
return nil
@@ -31,7 +37,7 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
if token != "" && token == g.ShareToken {
return nil
}
return &accessDenial{http.StatusNotFound, "Gallery not found"}
return &accessDenial{http.StatusForbidden, "This gallery needs its share link"}
case store.VisibilityTicket:
// The share token is honored as an escape hatch (e.g. attendee +1s
// without accounts, at the admin's discretion).
@@ -50,6 +56,6 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
}
return nil
default: // private
return &accessDenial{http.StatusNotFound, "Gallery not found"}
return &accessDenial{http.StatusForbidden, "This gallery is private"}
}
}