From 617c884012d096f973283f36334c6a4842d1adc3 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 06:48:05 +0000 Subject: [PATCH] Add view tokens for non-public gallery images and per-mode gate pages. 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 --- .../(public)/photos/[slug]/GalleryClient.tsx | 148 ++++++++++---- frontend/src/components/LoginModal.tsx | 190 ++++++++++++++++++ photo-api/internal/httpapi/access.go | 20 +- photo-api/internal/httpapi/api_test.go | 75 ++++++- photo-api/internal/httpapi/dto.go | 10 + photo-api/internal/httpapi/galleries.go | 13 +- photo-api/internal/httpapi/photos.go | 2 +- photo-api/internal/httpapi/public.go | 10 +- photo-api/internal/httpapi/viewtoken.go | 49 +++++ 9 files changed, 453 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/LoginModal.tsx create mode 100644 photo-api/internal/httpapi/viewtoken.go diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx index 29afb66..c7fd5ee 100644 --- a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -2,17 +2,19 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { photosApi, PhotoGallery, Photo } from '@/lib/api'; import Button from '@/components/ui/Button'; import { ImageGridSkeleton } from '@/components/ui/Skeleton'; import Lightbox from '@/components/Lightbox'; +import LoginModal from '@/components/LoginModal'; import { ArrowDownTrayIcon, CalendarIcon, CameraIcon, + LinkIcon, LockClosedIcon, TicketIcon, } from '@heroicons/react/24/outline'; @@ -25,15 +27,13 @@ interface GalleryClientProps { initial: { gallery: PhotoGallery; photos: Photo[] } | null; } -type DeniedState = 'login' | 'ticket' | 'notfound' | null; +type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null; export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) { const { locale } = useLanguage(); const es = locale === 'es'; const { user, isLoading: authLoading } = useAuth(); const searchParams = useSearchParams(); - const pathname = usePathname(); - const router = useRouter(); const shareToken = searchParams.get('token') || undefined; const [gallery, setGallery] = useState(initial?.gallery ?? null); @@ -41,6 +41,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien const [loading, setLoading] = useState(!initial); const [denied, setDenied] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); + const [loginOpen, setLoginOpen] = useState(false); // Server-rendered public galleries need no client fetch. Everything else // (link/ticket/private) is fetched here with the share token and/or the @@ -62,9 +63,13 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien }) .catch((err: Error) => { if (cancelled) return; + // The photo-api returns a distinct message per visibility mode so + // each gets its own gate page (see accessDenial in access.go). const msg = err.message || ''; if (msg.includes('Authentication required')) setDenied('login'); else if (msg.includes('attendees')) setDenied('ticket'); + else if (msg.includes('private')) setDenied('private'); + else if (msg.includes('share link')) setDenied('link'); else setDenied('notfound'); }) .finally(() => !cancelled && setLoading(false)); @@ -83,44 +88,117 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien ); } + // Gate pages for restricted galleries. After a successful login in the + // pop-up, AuthContext's user changes, which re-runs the fetch effect — + // the gate resolves by itself when access is granted. + const loginModal = ( + setLoginOpen(false)} + message={ + es + ? 'Inicia sesión para ver esta galería.' + : 'Log in to view this gallery.' + } + /> + ); + if (denied === 'login') { - const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`); return ( - router.push(`/login?redirect=${redirect}`)}> - {es ? 'Iniciar sesión' : 'Log in'} - - } - /> + <> + setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'} + } + /> + {loginModal} + ); } if (denied === 'ticket') { return ( - - - - ) : undefined - } - /> + <> + + {(eventSlug || gallery?.event) && ( + + + + )} + + + } + /> + {loginModal} + + ); + } + + if (denied === 'private') { + return ( + <> + + + + + + + } + /> + {loginModal} + + ); + } + + if (denied === 'link') { + return ( + <> + + + + + + + } + /> + {loginModal} + ); } diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx new file mode 100644 index 0000000..aca0648 --- /dev/null +++ b/frontend/src/components/LoginModal.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { useLanguage } from '@/context/LanguageContext'; +import { useAuth } from '@/context/AuthContext'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import GoogleSignInButton from '@/components/GoogleSignInButton'; +import { authApi } from '@/lib/api'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; + +interface LoginModalProps { + open: boolean; + onClose: () => void; + /** Called after a successful password login (Google logins reload the page). */ + onSuccess?: () => void; + /** Optional context line shown under the title (e.g. why login is needed). */ + message?: string; +} + +/** + * Reusable login pop-up: same capabilities as /login (password, + * magic link, Google) but inline, so the visitor stays on the page — + * used by gated photo galleries. + */ +export default function LoginModal({ open, onClose, onSuccess, message }: LoginModalProps) { + const { t, locale } = useLanguage(); + const es = locale === 'es'; + const { login } = useAuth(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [mode, setMode] = useState<'password' | 'magic-link'>('password'); + const [magicLinkSent, setMagicLinkSent] = useState(false); + const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + // Google logins hard-reload; send them back to exactly where they are + // (including a ?token= share link). + const query = searchParams.toString(); + const currentUrl = query ? `${pathname}?${query}` : pathname; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [open, onClose]); + + if (!open) return null; + + const handlePasswordLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await login(email, password); + toast.success(es ? '¡Bienvenido!' : 'Welcome back!'); + onClose(); + onSuccess?.(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('auth.errors.invalidCredentials')); + } finally { + setLoading(false); + } + }; + + const handleMagicLink = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) { + toast.error(es ? 'Ingresa tu email' : 'Please enter your email'); + return; + } + setLoading(true); + try { + await authApi.requestMagicLink(email); + setMagicLinkSent(true); + toast.success(es ? 'Revisa tu correo para el enlace de acceso' : 'Check your email for the login link'); + } catch (error) { + toast.error(error instanceof Error ? error.message : es ? 'Error' : 'Failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+ e.stopPropagation()}> +
+

{t('auth.login.title')}

+ +
+ {message &&

{message}

} + +
+ +
+ +
+
+ {es ? 'o' : 'or'} +
+
+ + {mode === 'password' ? ( +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + +
+ ) : magicLinkSent ? ( +

+ {es + ? 'Te enviamos un enlace de acceso. Abre tu correo y vuelve a esta página.' + : 'We sent you a login link. Open your email and come back to this page.'} +

+ ) : ( +
+ setEmail(e.target.value)} + /> + +
+ )} + +
+ + + {es ? 'Crear cuenta' : 'Create account'} + +
+ +
+ ); +} diff --git a/photo-api/internal/httpapi/access.go b/photo-api/internal/httpapi/access.go index e69086e..20caed5 100644 --- a/photo-api/internal/httpapi/access.go +++ b/photo-api/internal/httpapi/access.go @@ -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 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"} } } diff --git a/photo-api/internal/httpapi/api_test.go b/photo-api/internal/httpapi/api_test.go index 525e821..59a4a81 100644 --- a/photo-api/internal/httpapi/api_test.go +++ b/photo-api/internal/httpapi/api_test.go @@ -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 -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 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) } } diff --git a/photo-api/internal/httpapi/dto.go b/photo-api/internal/httpapi/dto.go index 0fcf53a..c66c7d0 100644 --- a/photo-api/internal/httpapi/dto.go +++ b/photo-api/internal/httpapi/dto.go @@ -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 "" diff --git a/photo-api/internal/httpapi/galleries.go b/photo-api/internal/httpapi/galleries.go index c5c2458..2158adf 100644 --- a/photo-api/internal/httpapi/galleries.go +++ b/photo-api/internal/httpapi/galleries.go @@ -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), }) } diff --git a/photo-api/internal/httpapi/photos.go b/photo-api/internal/httpapi/photos.go index c230f5d..1709827 100644 --- a/photo-api/internal/httpapi/photos.go +++ b/photo-api/internal/httpapi/photos.go @@ -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++ } diff --git a/photo-api/internal/httpapi/public.go b/photo-api/internal/httpapi/public.go index 98743dc..56156a1 100644 --- a/photo-api/internal/httpapi/public.go +++ b/photo-api/internal/httpapi/public.go @@ -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 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 { diff --git a/photo-api/internal/httpapi/viewtoken.go b/photo-api/internal/httpapi/viewtoken.go new file mode 100644 index 0000000..be2ddf0 --- /dev/null +++ b/photo-api/internal/httpapi/viewtoken.go @@ -0,0 +1,49 @@ +package httpapi + +// View tokens solve a browser reality: the gallery JSON is fetched with an +// Authorization header, but 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)) +}