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:
co-authored by
Claude Opus 4.6
parent
93476ac72a
commit
617c884012
@@ -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"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,16 @@ type photoJSON struct {
|
||||
URLs photoURLs `json:"urls"`
|
||||
}
|
||||
|
||||
// viewTokenFor returns the token to embed in a gallery's file URLs: none
|
||||
// for public galleries (files are anonymously accessible), a short-lived
|
||||
// gallery-scoped view token otherwise (see viewtoken.go).
|
||||
func (s *Server) viewTokenFor(g store.Gallery) string {
|
||||
if g.Visibility == store.VisibilityPublic {
|
||||
return ""
|
||||
}
|
||||
return mintViewToken([]byte(s.cfg.JWTSecret), g.ID)
|
||||
}
|
||||
|
||||
func isoTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
"gallery": s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.Us
|
||||
}
|
||||
out := make([]galleryJSON, 0, len(galleries))
|
||||
for _, g := range galleries {
|
||||
out = append(out, s.galleryToJSON(r.Context(), g, "", true))
|
||||
out = append(out, s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
|
||||
}
|
||||
@@ -106,12 +106,13 @@ func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User)
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
urlToken := s.viewTokenFor(g)
|
||||
out := make([]photoJSON, 0, len(photos))
|
||||
for _, p := range photos {
|
||||
out = append(out, s.photoToJSON(p, "", true))
|
||||
out = append(out, s.photoToJSON(p, urlToken, true))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
"gallery": s.galleryToJSON(r.Context(), g, urlToken, true),
|
||||
"photos": out,
|
||||
})
|
||||
}
|
||||
@@ -174,7 +175,7 @@ func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.Us
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
"gallery": s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,7 +212,7 @@ func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
"gallery": s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.Use
|
||||
writeError(w, uploadErr.status, uploadErr.msg)
|
||||
return
|
||||
}
|
||||
created = append(created, s.photoToJSON(photo, "", true))
|
||||
created = append(created, s.photoToJSON(photo, s.viewTokenFor(g), true))
|
||||
position++
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,10 @@ func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g st
|
||||
return
|
||||
}
|
||||
|
||||
// Only pass the token through to file URLs when it was the granting
|
||||
// credential, so it doesn't leak into public/ticket responses.
|
||||
urlToken := ""
|
||||
if token != "" && token == g.ShareToken {
|
||||
urlToken = token
|
||||
}
|
||||
// Non-public galleries get a short-lived view token on every file URL,
|
||||
// because the browser's <img> requests cannot carry the Bearer header
|
||||
// that authorized this gallery fetch. Public galleries need none.
|
||||
urlToken := s.viewTokenFor(g)
|
||||
|
||||
photos, err := s.db.ListPhotos(r.Context(), g.ID, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package httpapi
|
||||
|
||||
// View tokens solve a browser reality: the gallery JSON is fetched with an
|
||||
// Authorization header, but <img> tags cannot send headers, so image
|
||||
// requests for non-public galleries would arrive anonymous and be denied.
|
||||
// After a viewer passes the gallery access check, the server mints a
|
||||
// short-lived HMAC token scoped to that one gallery and appends it to the
|
||||
// file URLs it returns — the same idea as an S3 presigned URL. Possession
|
||||
// grants view access to that gallery only, until expiry.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Expiries are aligned to hour buckets so repeated requests within the
|
||||
// same hour mint the *same* token — image URLs stay stable and the
|
||||
// browser cache keeps working across refetches/polls. A token minted at
|
||||
// time t expires between 1h and 2h later.
|
||||
const viewTokenBucket = int64(3600)
|
||||
|
||||
func mintViewToken(secret []byte, galleryID string) string {
|
||||
exp := (time.Now().Unix()/viewTokenBucket + 2) * viewTokenBucket
|
||||
return fmt.Sprintf("v1.%d.%s", exp, viewTokenSig(secret, galleryID, exp))
|
||||
}
|
||||
|
||||
func verifyViewToken(secret []byte, galleryID, token string) bool {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || parts[0] != "v1" {
|
||||
return false
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp {
|
||||
return false
|
||||
}
|
||||
expected := viewTokenSig(secret, galleryID, exp)
|
||||
return hmac.Equal([]byte(expected), []byte(parts[2]))
|
||||
}
|
||||
|
||||
func viewTokenSig(secret []byte, galleryID string, exp int64) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
fmt.Fprintf(mac, "photos-view:%s:%d", galleryID, exp)
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user