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
@@ -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<PhotoGallery | null>(initial?.gallery ?? null);
@@ -41,6 +41,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
const [loading, setLoading] = useState(!initial);
const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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 = (
<LoginModal
open={loginOpen}
onClose={() => 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 (
<GateMessage
icon={LockClosedIcon}
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
{es ? 'Iniciar sesión' : 'Log in'}
</Button>
}
/>
<>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
}
/>
{loginModal}
</>
);
}
if (denied === 'ticket') {
return (
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
}
action={
eventSlug || gallery?.event ? (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
) : undefined
}
/>
<>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
}
action={
<div className="flex flex-wrap justify-center gap-3">
{(eventSlug || gallery?.event) && (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
)}
<Button onClick={() => setLoginOpen(true)}>
{es ? 'Cambiar de cuenta' : 'Switch account'}
</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'private') {
return (
<>
<GateMessage
icon={LockClosedIcon}
title={es ? 'Esta galería es privada' : 'This gallery is private'}
body={
es
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
: 'Only the organizers can see it. If thats you, log in.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'link') {
return (
<>
<GateMessage
icon={LinkIcon}
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
body={
es
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if youre part of the team.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
);
}
+190
View File
@@ -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 (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<Card className="w-full max-w-md p-6 md:p-8" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between mb-1">
<h2 className="text-xl font-bold text-primary-dark">{t('auth.login.title')}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 -m-1" aria-label="Close">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{message && <p className="text-sm text-gray-600 mb-4">{message}</p>}
<div className="mt-4">
<GoogleSignInButton redirectTo={currentUrl} />
</div>
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-secondary-light-gray" />
<span className="text-xs text-gray-500">{es ? 'o' : 'or'}</span>
<div className="flex-1 h-px bg-secondary-light-gray" />
</div>
{mode === 'password' ? (
<form onSubmit={handlePasswordLogin} className="space-y-4">
<Input
id="login-modal-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="login-modal-password"
label={t('auth.login.password')}
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{t('auth.login.submit')}
</Button>
</form>
) : magicLinkSent ? (
<p className="text-sm text-gray-600 text-center py-4">
{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.'}
</p>
) : (
<form onSubmit={handleMagicLink} className="space-y-4">
<Input
id="login-modal-magic-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{es ? 'Enviarme un enlace de acceso' : 'Email me a login link'}
</Button>
</form>
)}
<div className="mt-4 flex items-center justify-between text-sm">
<button
type="button"
onClick={() => {
setMode(mode === 'password' ? 'magic-link' : 'password');
setMagicLinkSent(false);
}}
className="text-secondary-blue hover:underline"
>
{mode === 'password'
? es
? 'Entrar con enlace por correo'
: 'Log in with an email link'
: es
? 'Entrar con contraseña'
: 'Log in with a password'}
</button>
<Link href="/register" className="text-secondary-blue hover:underline" onClick={onClose}>
{es ? 'Crear cuenta' : 'Create account'}
</Link>
</div>
</Card>
</div>
);
}
+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"}
}
}
+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)
}
}
+10
View File
@@ -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 ""
+7 -6
View File
@@ -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),
})
}
+1 -1
View File
@@ -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++
}
+4 -6
View File
@@ -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 {
+49
View File
@@ -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))
}