From c9a600b6d6e4739e30be8c0640ecfdd0eeb0a2a2 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 25 Jul 2026 17:32:23 +0000 Subject: [PATCH] Add photo galleries API and frontend for public and admin viewing. Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor --- .gitignore | 5 + deploy/back-end_nginx.conf | 23 + deploy/front-end_nginx.conf | 18 + deploy/spanglish-photos.service | 30 + deploy/spanglish_upstreams.conf | 4 + frontend/.env.example | 5 + frontend/next.config.js | 11 + .../app/(public)/events/[id]/gallery/page.tsx | 50 ++ .../app/(public)/photos/PhotosIndexClient.tsx | 72 ++ .../(public)/photos/[slug]/GalleryClient.tsx | 303 +++++++ .../src/app/(public)/photos/[slug]/page.tsx | 51 ++ frontend/src/app/(public)/photos/page.tsx | 30 + frontend/src/app/admin/layout.tsx | 2 + .../src/app/admin/photos/VisibilityBadge.tsx | 32 + frontend/src/app/admin/photos/[id]/page.tsx | 617 ++++++++++++++ frontend/src/app/admin/photos/page.tsx | 221 +++++ frontend/src/app/sitemap.ts | 41 +- frontend/src/components/Lightbox.tsx | 145 ++++ frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/lib/api/index.ts | 8 + frontend/src/lib/api/photos.ts | 176 ++++ frontend/tsconfig.json | 22 +- package.json | 6 +- photo-api/.env.example | 43 + photo-api/Dockerfile | 25 + photo-api/Makefile | 23 + photo-api/PLAN.md | 334 ++++++++ photo-api/README.md | 92 +++ photo-api/cmd/photo-api/main.go | 106 +++ photo-api/go.mod | 46 ++ photo-api/go.sum | 117 +++ photo-api/internal/auth/auth.go | 99 +++ photo-api/internal/config/config.go | 126 +++ photo-api/internal/httpapi/access.go | 55 ++ photo-api/internal/httpapi/api_test.go | 504 ++++++++++++ photo-api/internal/httpapi/dto.go | 153 ++++ photo-api/internal/httpapi/files.go | 117 +++ photo-api/internal/httpapi/galleries.go | 225 ++++++ photo-api/internal/httpapi/middleware.go | 36 + photo-api/internal/httpapi/photos.go | 242 ++++++ photo-api/internal/httpapi/public.go | 79 ++ photo-api/internal/httpapi/respond.go | 39 + photo-api/internal/httpapi/server.go | 90 +++ photo-api/internal/httpapi/slug.go | 72 ++ photo-api/internal/imaging/heic.go | 60 ++ photo-api/internal/imaging/imaging.go | 159 ++++ photo-api/internal/imaging/sniff.go | 30 + photo-api/internal/storage/local.go | 91 +++ photo-api/internal/storage/s3.go | 92 +++ photo-api/internal/storage/storage.go | 32 + photo-api/internal/store/access.go | 113 +++ photo-api/internal/store/db.go | 122 +++ photo-api/internal/store/galleries.go | 289 +++++++ photo-api/internal/store/migrate.go | 115 +++ photo-api/internal/store/photos.go | 271 +++++++ photo-api/internal/worker/worker.go | 185 +++++ photo-api/migrations/0001_init.pg.sql | 46 ++ photo-api/migrations/0001_init.sqlite.sql | 46 ++ photo-api/migrations/embed.go | 8 + pnpm-lock.yaml | 763 ++++++++++++++++++ 61 files changed, 6912 insertions(+), 7 deletions(-) create mode 100644 deploy/spanglish-photos.service create mode 100644 frontend/src/app/(public)/events/[id]/gallery/page.tsx create mode 100644 frontend/src/app/(public)/photos/PhotosIndexClient.tsx create mode 100644 frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx create mode 100644 frontend/src/app/(public)/photos/[slug]/page.tsx create mode 100644 frontend/src/app/(public)/photos/page.tsx create mode 100644 frontend/src/app/admin/photos/VisibilityBadge.tsx create mode 100644 frontend/src/app/admin/photos/[id]/page.tsx create mode 100644 frontend/src/app/admin/photos/page.tsx create mode 100644 frontend/src/components/Lightbox.tsx create mode 100644 frontend/src/lib/api/photos.ts create mode 100644 photo-api/.env.example create mode 100644 photo-api/Dockerfile create mode 100644 photo-api/Makefile create mode 100644 photo-api/PLAN.md create mode 100644 photo-api/README.md create mode 100644 photo-api/cmd/photo-api/main.go create mode 100644 photo-api/go.mod create mode 100644 photo-api/go.sum create mode 100644 photo-api/internal/auth/auth.go create mode 100644 photo-api/internal/config/config.go create mode 100644 photo-api/internal/httpapi/access.go create mode 100644 photo-api/internal/httpapi/api_test.go create mode 100644 photo-api/internal/httpapi/dto.go create mode 100644 photo-api/internal/httpapi/files.go create mode 100644 photo-api/internal/httpapi/galleries.go create mode 100644 photo-api/internal/httpapi/middleware.go create mode 100644 photo-api/internal/httpapi/photos.go create mode 100644 photo-api/internal/httpapi/public.go create mode 100644 photo-api/internal/httpapi/respond.go create mode 100644 photo-api/internal/httpapi/server.go create mode 100644 photo-api/internal/httpapi/slug.go create mode 100644 photo-api/internal/imaging/heic.go create mode 100644 photo-api/internal/imaging/imaging.go create mode 100644 photo-api/internal/imaging/sniff.go create mode 100644 photo-api/internal/storage/local.go create mode 100644 photo-api/internal/storage/s3.go create mode 100644 photo-api/internal/storage/storage.go create mode 100644 photo-api/internal/store/access.go create mode 100644 photo-api/internal/store/db.go create mode 100644 photo-api/internal/store/galleries.go create mode 100644 photo-api/internal/store/migrate.go create mode 100644 photo-api/internal/store/photos.go create mode 100644 photo-api/internal/worker/worker.go create mode 100644 photo-api/migrations/0001_init.pg.sql create mode 100644 photo-api/migrations/0001_init.sqlite.sql create mode 100644 photo-api/migrations/embed.go create mode 100644 pnpm-lock.yaml diff --git a/.gitignore b/.gitignore index ffc5a40..26037d1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ package-lock.json # Build outputs dist/ .next/ +.next-build/ out/ build/ @@ -56,6 +57,10 @@ yarn-error.log* # Testing coverage/ +# Go photo-api service +photo-api/bin/ +photo-api/data/ + # Misc *.pem diff --git a/deploy/back-end_nginx.conf b/deploy/back-end_nginx.conf index 589202d..ff48e0b 100644 --- a/deploy/back-end_nginx.conf +++ b/deploy/back-end_nginx.conf @@ -63,6 +63,29 @@ server { return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; } + # Photo gallery service (photo-api, port 3020). ^~ wins over the / + # prefix below; bigger body cap and unbuffered uploads for photo batches. + location ^~ /api/photos/ { + limit_req zone=spanglish_api_limit burst=50 nodelay; + + proxy_pass http://spanglish_photos; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Strip CORS headers from the service (nginx handles CORS here) + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + location / { limit_req zone=spanglish_api_limit burst=50 nodelay; diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 9b96a40..c6b6225 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -79,6 +79,24 @@ server { proxy_connect_timeout 300s; } + # Photo gallery service (photo-api, port 3020). ^~ wins over the /api + # prefix below. Batch photo uploads are large and slow, so the body cap + # is raised and request buffering is off for this location only. + location ^~ /api/photos/ { + proxy_pass http://spanglish_photos; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + # Proxy /api to backend location /api { proxy_pass http://spanglish_backend; diff --git a/deploy/spanglish-photos.service b/deploy/spanglish-photos.service new file mode 100644 index 0000000..29de8cc --- /dev/null +++ b/deploy/spanglish-photos.service @@ -0,0 +1,30 @@ +[Unit] +Description=Spanglish Photo Gallery API +Documentation=https://git.azzamo.net/Michilis/Spanglish +After=network.target spanglish-backend.service + +[Service] +Type=simple +User=spanglish +Group=spanglish +WorkingDirectory=/home/spanglish/Spanglish/photo-api +EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env +Environment=PORT=3020 +# Apply pending photos_* migrations before serving (idempotent, advisory-locked) +ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate +ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api +Restart=on-failure +RestartSec=10 +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=spanglish-photos + +# Security hardening (mirrors spanglish-backend.service) +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/spanglish/Spanglish/photo-api/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/spanglish_upstreams.conf b/deploy/spanglish_upstreams.conf index 26b2462..f472d28 100644 --- a/deploy/spanglish_upstreams.conf +++ b/deploy/spanglish_upstreams.conf @@ -4,4 +4,8 @@ upstream spanglish_frontend { upstream spanglish_backend { server 127.0.0.1:3018; +} + +upstream spanglish_photos { + server 127.0.0.1:3020; } \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index c7d9259..463c8ba 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com # API URL (leave empty for same-origin proxy) NEXT_PUBLIC_API_URL= +# Photo gallery service (photo-api) origin, used by the /api/photos rewrite +# and server-side rendering of /photos pages. +# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020 +PHOTO_API_URL= + # Google OAuth (optional - leave empty to hide Google Sign-In button) # Get your Client ID from: https://console.cloud.google.com/apis/credentials # 1. Create a new OAuth 2.0 Client ID (Web application) diff --git a/frontend/next.config.js b/frontend/next.config.js index f34c070..7f20796 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -4,6 +4,10 @@ // being hardcoded to localhost. const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; +// The standalone photo gallery service (photo-api/). Must be rewritten +// before the generic /api rule below — Next rewrites are order-sensitive. +const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003'; + // Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN). const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '') .split(',') @@ -20,6 +24,9 @@ const securityHeaders = [ ]; const nextConfig = { + // Overridable so a production build can run while `next dev` holds .next + // (e.g. NEXT_DIST_DIR=.next-build npm run build). + distDir: process.env.NEXT_DIST_DIR || '.next', images: { // Restrict remote image sources to a known allowlist instead of allowing any // https host (which let the Next image optimizer be used as an open proxy). @@ -39,6 +46,10 @@ const nextConfig = { }, async rewrites() { return [ + { + source: '/api/photos/:path*', + destination: `${PHOTO_API_URL}/api/photos/:path*`, + }, { source: '/api/:path*', destination: `${BACKEND_URL}/api/:path*`, diff --git a/frontend/src/app/(public)/events/[id]/gallery/page.tsx b/frontend/src/app/(public)/events/[id]/gallery/page.tsx new file mode 100644 index 0000000..1d4b005 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/gallery/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import type { PhotoGallery, Photo } from '@/lib/api'; +import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient'; + +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface PageProps { + params: { id: string }; // event slug, same param name as the parent route +} + +// Public event galleries render server-side; link/ticket galleries return +// null here and are fetched client-side with the viewer's token. +async function getEventGallery( + eventSlug: string +): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> { + try { + const res = await fetch( + `${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`, + { next: { revalidate: 300 } } + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const data = await getEventGallery(params.id); + if (!data) { + return { title: 'Event Photos', robots: { index: false } }; + } + return { + title: `${data.gallery.title} – Photos`, + description: + data.gallery.description || + `Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`, + }; +} + +export default async function EventGalleryPage({ params }: PageProps) { + const initial = await getEventGallery(params.id); + return ( + // Suspense boundary required by useSearchParams() in the client child. + + + + ); +} diff --git a/frontend/src/app/(public)/photos/PhotosIndexClient.tsx b/frontend/src/app/(public)/photos/PhotosIndexClient.tsx new file mode 100644 index 0000000..da01cf7 --- /dev/null +++ b/frontend/src/app/(public)/photos/PhotosIndexClient.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import type { PhotoGallery } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import { CameraIcon } from '@heroicons/react/24/outline'; + +export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) { + const { locale } = useLanguage(); + const es = locale === 'es'; + + const formatDate = (iso?: string) => { + if (!iso) return null; + return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + timeZone: 'America/Asuncion', + }); + }; + + return ( +
+
+

+ {es ? 'Galerías de Fotos' : 'Photo Galleries'} +

+

+ {es + ? 'Recuerdos de nuestros intercambios de idiomas en Asunción' + : 'Memories from our language exchanges in Asunción'} +

+ + {galleries.length === 0 ? ( +
+ +

+ {es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'} +

+
+ ) : ( +
+ {galleries.map((g) => ( + // Event-linked galleries live under the event's URL. + + +
+ {g.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+
+

+ {es && g.titleEs ? g.titleEs : g.title} +

+

+ {g.photoCount} {es ? 'fotos' : 'photos'} + {g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}} +

+
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx new file mode 100644 index 0000000..c75aa2c --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useSearchParams, useRouter, usePathname } 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 { + ArrowDownTrayIcon, + CalendarIcon, + CameraIcon, + LockClosedIcon, + TicketIcon, +} from '@heroicons/react/24/outline'; + +interface GalleryClientProps { + /** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */ + slug?: string; + /** … or by event slug (event galleries at /events/[slug]/gallery). */ + eventSlug?: string; + initial: { gallery: PhotoGallery; photos: Photo[] } | null; +} + +type DeniedState = 'login' | 'ticket' | '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); + const [photos, setPhotos] = useState(initial?.photos ?? []); + const [loading, setLoading] = useState(!initial); + const [denied, setDenied] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // Server-rendered public galleries need no client fetch. Everything else + // (link/ticket/private) is fetched here with the share token and/or the + // viewer's own auth token attached. + useEffect(() => { + if (initial) return; + if (authLoading) return; // wait so the Bearer token is available + let cancelled = false; + setLoading(true); + const fetcher = eventSlug + ? photosApi.getEventGallery(eventSlug, shareToken) + : photosApi.getPublicGallery(slug || '', shareToken); + fetcher + .then((data) => { + if (cancelled) return; + setGallery(data.gallery); + setPhotos(data.photos); + setDenied(null); + }) + .catch((err: Error) => { + if (cancelled) return; + const msg = err.message || ''; + if (msg.includes('Authentication required')) setDenied('login'); + else if (msg.includes('attendees')) setDenied('ticket'); + else setDenied('notfound'); + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); + + if (loading || (authLoading && !initial)) { + return ( +
+
+ +
+
+ ); + } + + if (denied === 'login') { + const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`); + return ( + router.push(`/login?redirect=${redirect}`)}> + {es ? 'Iniciar sesión' : 'Log in'} + + } + /> + ); + } + + if (denied === 'ticket') { + return ( + + + + ) : undefined + } + /> + ); + } + + if (denied === 'notfound' || !gallery) { + return ( + + + + } + /> + ); + } + + const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb); + const lightboxItems = readyPhotos.map((p) => ({ + id: p.id, + previewUrl: p.urls.preview || p.urls.original, + downloadUrl: p.urls.original, + filename: p.originalFilename, + })); + + const title = es && gallery.titleEs ? gallery.titleEs : gallery.title; + const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description; + const eventTitle = gallery.event + ? es && gallery.event.titleEs + ? gallery.event.titleEs + : gallery.event.title + : null; + const eventDate = gallery.event?.startDatetime + ? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'America/Asuncion', + }) + : null; + + // Hero backdrop: the cover photo's preview when available, else the + // first photo. Falls back to a navy gradient with no image. + const coverPhoto = + readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null; + const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null; + + return ( +
+ {/* Hero */} +
+ {heroUrl && ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + )} +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ + + {readyPhotos.length} {es ? 'fotos' : 'photos'} + + {gallery.event && ( + + + {eventTitle} + {eventDate && · {eventDate}} + + )} +
+
+
+ + {/* Masonry grid */} +
+ {readyPhotos.length === 0 ? ( +
+ +

+ {es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'} +

+
+ ) : ( +
+ {readyPhotos.map((photo, i) => ( +
setLightboxIndex(i)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setLightboxIndex(i); + } + }} + className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow" + style={ + photo.width && photo.height + ? { aspectRatio: `${photo.width} / ${photo.height}` } + : undefined + } + > + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ))} +
+ )} + + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + /> + )} +
+
+ ); +} + +function GateMessage({ + icon: Icon, + title, + body, + action, +}: { + icon: typeof CameraIcon; + title: string; + body: string; + action?: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+

{body}

+ {action} +
+
+ ); +} diff --git a/frontend/src/app/(public)/photos/[slug]/page.tsx b/frontend/src/app/(public)/photos/[slug]/page.tsx new file mode 100644 index 0000000..736af70 --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import type { PhotoGallery, Photo } from '@/lib/api'; +import GalleryClient from './GalleryClient'; + +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface PageProps { + params: { slug: string }; +} + +// Public galleries render server-side (SEO + fast first paint); link and +// ticket galleries return null here and are fetched client-side with the +// viewer's token / share token. +async function getPublicGallery( + slug: string +): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> { + try { + const res = await fetch( + `${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`, + { next: { revalidate: 300 } } + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const data = await getPublicGallery(params.slug); + if (!data) { + return { title: 'Photo Gallery', robots: { index: false } }; + } + return { + title: `${data.gallery.title} – Photos`, + description: + data.gallery.description || + `Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`, + }; +} + +export default async function GalleryPage({ params }: PageProps) { + const initial = await getPublicGallery(params.slug); + return ( + // Suspense boundary required by useSearchParams() in the client child. + + + + ); +} diff --git a/frontend/src/app/(public)/photos/page.tsx b/frontend/src/app/(public)/photos/page.tsx new file mode 100644 index 0000000..62f774f --- /dev/null +++ b/frontend/src/app/(public)/photos/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next'; +import type { PhotoGallery } from '@/lib/api'; +import PhotosIndexClient from './PhotosIndexClient'; + +// Server-side calls go straight to the photo-api service; the browser uses +// the same-origin /api/photos path via the Next rewrite / nginx. +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +export const metadata: Metadata = { + title: 'Event Photo Galleries', + description: 'Photos from past Spanglish language exchange events in Asunción.', +}; + +async function getGalleries(): Promise { + try { + const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, { + next: { revalidate: 300 }, + }); + if (!res.ok) return []; + const data = await res.json(); + return data.galleries || []; + } catch { + return []; + } +} + +export default async function PhotosPage() { + const galleries = await getGalleries(); + return ; +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index d4cdced..16a27ec 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -17,6 +17,7 @@ import { EnvelopeIcon, InboxIcon, PhotoIcon, + CameraIcon, Cog6ToothIcon, ArrowLeftOnRectangleIcon, Bars3Icon, @@ -54,6 +55,7 @@ export default function AdminLayout({ { name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] }, { name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] }, { name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] }, + { name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] }, { name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] }, { name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] }, { name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] }, diff --git a/frontend/src/app/admin/photos/VisibilityBadge.tsx b/frontend/src/app/admin/photos/VisibilityBadge.tsx new file mode 100644 index 0000000..258c8b9 --- /dev/null +++ b/frontend/src/app/admin/photos/VisibilityBadge.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useLanguage } from '@/context/LanguageContext'; +import type { GalleryVisibility } from '@/lib/api'; +import { + GlobeAltIcon, + LockClosedIcon, + LinkIcon, + TicketIcon, +} from '@heroicons/react/24/outline'; + +const visibilityMeta: Record< + GalleryVisibility, + { icon: typeof GlobeAltIcon; className: string; en: string; es: string } +> = { + public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' }, + private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' }, + link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' }, + ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' }, +}; + +export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) { + const { locale } = useLanguage(); + const meta = visibilityMeta[visibility] || visibilityMeta.private; + const Icon = meta.icon; + return ( + + + {locale === 'es' ? meta.es : meta.en} + + ); +} diff --git a/frontend/src/app/admin/photos/[id]/page.tsx b/frontend/src/app/admin/photos/[id]/page.tsx new file mode 100644 index 0000000..6b6808c --- /dev/null +++ b/frontend/src/app/admin/photos/[id]/page.tsx @@ -0,0 +1,617 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton'; +import Lightbox from '@/components/Lightbox'; +import VisibilityBadge from '../VisibilityBadge'; +import { + ArrowLeftIcon, + ArrowUpTrayIcon, + ArrowPathIcon, + CheckIcon, + CheckCircleIcon, + ChevronDownIcon, + ChevronUpIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + LinkIcon, + PhotoIcon, + StarIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; +import toast from 'react-hot-toast'; + +interface UploadItem { + key: string; + name: string; + sizeBytes: number; + progress: number; // 0..1 while uploading + status: 'queued' | 'uploading' | 'processing' | 'error'; + error?: string; + photoId?: string; +} + +function formatBytes(n: number) { + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; + if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`; + return `${n} B`; +} + +export default function AdminGalleryDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { locale } = useLanguage(); + const es = locale === 'es'; + + const [gallery, setGallery] = useState(null); + const [photos, setPhotos] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + const [dragId, setDragId] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + const fileInputRef = useRef(null); + + // Uploader panel state (Google Drive style: one row per file). + const [uploads, setUploads] = useState([]); + const [panelCollapsed, setPanelCollapsed] = useState(false); + const uploadQueue = useRef<{ key: string; file: File }[]>([]); + const pumpRunning = useRef(false); + + const load = useCallback(async () => { + try { + const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]); + setGallery(detail.gallery); + setPhotos(detail.photos); + setEvents(ev.events); + } catch { + toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery'); + router.push('/admin/photos'); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + // Poll while any photo is still processing so thumbnails (and the + // uploader panel rows) update as the worker finishes them. + const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length; + useEffect(() => { + if (processingCount === 0) return; + const timer = setInterval(async () => { + try { + const detail = await photosApi.getGallery(id); + setGallery(detail.gallery); + setPhotos(detail.photos); + } catch { + /* transient; next tick retries */ + } + }, 3000); + return () => clearInterval(timer); + }, [processingCount, id]); + + const patchUpload = (key: string, patch: Partial) => { + setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u))); + }; + + // Sequential upload pump: one file at a time, byte progress per file. + const pump = useCallback(async () => { + if (pumpRunning.current) return; + pumpRunning.current = true; + while (uploadQueue.current.length > 0) { + const { key, file } = uploadQueue.current.shift()!; + patchUpload(key, { status: 'uploading', progress: 0 }); + try { + const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) => + patchUpload(key, { progress: fraction }) + ); + setPhotos((prev) => [...prev, ...added]); + patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id }); + } catch (err) { + patchUpload(key, { + status: 'error', + error: err instanceof Error ? err.message : 'Upload failed', + }); + } + } + pumpRunning.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const handleUpload = (files: FileList | File[]) => { + const list = Array.from(files); + if (list.length === 0) return; + const items: UploadItem[] = list.map((file) => ({ + key: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: file.name, + sizeBytes: file.size, + progress: 0, + status: 'queued', + })); + uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] }))); + setUploads((prev) => [...prev, ...items]); + setPanelCollapsed(false); + pump(); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + // A row is "done" once its photo finished processing; the panel derives + // this from the photos list instead of tracking it separately. + const displayStatus = (u: UploadItem): { state: string; error?: string } => { + if (u.status === 'processing' && u.photoId) { + const photo = photos.find((p) => p.id === u.photoId); + if (photo?.status === 'ready') return { state: 'done' }; + if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' }; + } + return { state: u.status, error: u.error }; + }; + + const activeUploads = uploads.filter((u) => { + const s = displayStatus(u).state; + return s === 'queued' || s === 'uploading' || s === 'processing'; + }).length; + + const saveField = async (patch: Parameters[1], okMsg?: string) => { + try { + const { gallery: updated } = await photosApi.updateGallery(id, patch); + setGallery(updated); + if (okMsg) toast.success(okMsg); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const copyShareLink = async () => { + if (!gallery?.shareUrl) return; + try { + await navigator.clipboard.writeText(gallery.shareUrl); + setCopied(true); + toast.success(es ? 'Enlace copiado' : 'Share link copied'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(es ? 'No se pudo copiar' : 'Failed to copy'); + } + }; + + const rotateToken = async () => { + if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?')) + return; + try { + const { gallery: updated } = await photosApi.rotateShareToken(id); + setGallery(updated); + toast.success(es ? 'Nuevo enlace generado' : 'New share link generated'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed'); + } + }; + + const deletePhoto = async (photoId: string): Promise => { + if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false; + try { + await photosApi.deletePhoto(photoId); + setPhotos((prev) => prev.filter((p) => p.id !== photoId)); + toast.success(es ? 'Foto eliminada' : 'Photo deleted'); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete'); + return false; + } + }; + + const retryPhoto = async (photoId: string) => { + try { + const { photo } = await photosApi.retryPhoto(photoId); + setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p))); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to retry'); + } + }; + + const deleteGallery = async () => { + if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return; + try { + await photosApi.deleteGallery(id); + toast.success(es ? 'Galería eliminada' : 'Gallery deleted'); + router.push('/admin/photos'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete'); + } + }; + + // HTML5 drag-and-drop reorder; persisted on drop. + const onDropReorder = async (targetId: string) => { + if (!dragId || dragId === targetId) return; + const ids = photos.map((p) => p.id); + const from = ids.indexOf(dragId); + const to = ids.indexOf(targetId); + if (from < 0 || to < 0) return; + ids.splice(to, 0, ids.splice(from, 1)[0]); + const reordered = ids + .map((pid) => photos.find((p) => p.id === pid)) + .filter((p): p is Photo => !!p); + setPhotos(reordered); + setDragId(null); + try { + await photosApi.reorderPhotos(id, ids); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save order'); + load(); + } + }; + + if (loading || !gallery) { + return ( +
+ + +
+ ); + } + + const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb); + const lightboxItems = readyPhotos.map((p) => ({ + id: p.id, + previewUrl: p.urls.preview || p.urls.original, + downloadUrl: p.urls.original, + filename: p.originalFilename, + })); + const openLightboxFor = (photoId: string) => { + const idx = readyPhotos.findIndex((p) => p.id === photoId); + if (idx >= 0) setLightboxIndex(idx); + }; + + return ( +
{ + if (e.dataTransfer.types.includes('Files')) e.preventDefault(); + }} + onDrop={(e) => { + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + handleUpload(e.dataTransfer.files); + } + }} + > +
+
+ + + +

+ {es && gallery.titleEs ? gallery.titleEs : gallery.title} +

+ +
+
+ + e.target.files && handleUpload(e.target.files)} + className="hidden" + /> +
+
+ + {/* Settings */} + +
+
+ + +
+
+ + + {gallery.visibility === 'ticket' && !gallery.eventId && ( +

+ {es + ? 'El modo "con entrada" necesita un evento vinculado' + : 'Ticket mode needs a linked event'} +

+ )} +
+
+ +
+ + + +
+ {gallery.event && ( +

+ {es + ? 'Publicada en la página del evento: ' + : 'Published on the event page: '} + /events/{gallery.event.slug}/gallery +

+ )} +
+
+
+ + {processingCount > 0 && ( +

+ {es + ? `Procesando ${processingCount} foto(s)…` + : `Processing ${processingCount} photo(s)…`} +

+ )} + + {/* Photo grid */} + {photos.length === 0 ? ( + + +

+ {es + ? 'Arrastra fotos aquí o usa el botón de subir' + : 'Drag photos here or use the upload button'} +

+
+ ) : ( +
+ {photos.map((photo) => ( + setDragId(photo.id)} + onDragOver={(e) => { + if (dragId) e.preventDefault(); + }} + onDrop={(e) => { + if (dragId) { + e.preventDefault(); + e.stopPropagation(); + onDropReorder(photo.id); + } + }} + className={`group relative overflow-hidden aspect-square cursor-grab ${ + dragId === photo.id ? 'opacity-50' : '' + }`} + > + {photo.status === 'ready' && photo.urls.thumb ? ( + // eslint-disable-next-line @next/next/no-img-element + openLightboxFor(photo.id)} + /> + ) : photo.status === 'failed' ? ( +
+ + {photo.lastError || 'Failed'} +
+ ) : ( +
+
+
+ )} + + {gallery.coverPhotoId === photo.id && ( + + )} + +
+ {photo.status === 'ready' && ( + + )} + {photo.status === 'failed' && ( + + )} + +
+ + ))} +
+ )} + + {/* Danger zone */} +
+ +
+ + {/* Lightbox with admin actions */} + {lightboxIndex !== null && lightboxItems.length > 0 && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + renderActions={(item) => ( + <> + + + + )} + /> + )} + + {/* Uploader panel (Google Drive style) */} + {uploads.length > 0 && ( +
+
+
+

+ {activeUploads > 0 + ? es + ? `Subiendo ${uploads.length - activeUploads}/${uploads.length}…` + : `Uploading ${uploads.length - activeUploads}/${uploads.length}…` + : es + ? `${uploads.length} subida(s) completada(s)` + : `${uploads.length} upload(s) complete`} +

+
+ + {activeUploads === 0 && ( + + )} +
+
+ {!panelCollapsed && ( +
    + {uploads.map((u) => { + const ds = displayStatus(u); + return ( +
  • +
    + +
    +

    + {u.name} +

    +

    + {ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`} + {ds.state === 'queued' && (es ? 'En cola' : 'Queued')} + {ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')} + {ds.state === 'done' && formatBytes(u.sizeBytes)} + {ds.state === 'error' && ( + + {ds.error} + + )} +

    +
    + + {ds.state === 'done' && } + {ds.state === 'error' && } + {ds.state === 'processing' && ( +
    + )} + {ds.state === 'uploading' && ( + {Math.round(u.progress * 100)}% + )} + +
    + {ds.state === 'uploading' && ( +
    +
    +
    + )} +
  • + ); + })} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/photos/page.tsx b/frontend/src/app/admin/photos/page.tsx new file mode 100644 index 0000000..693cd65 --- /dev/null +++ b/frontend/src/app/admin/photos/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton'; +import VisibilityBadge from './VisibilityBadge'; +import { + CameraIcon, + PlusIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; + +export default function AdminPhotosPage() { + const { locale } = useLanguage(); + const [galleries, setGalleries] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState({ + title: '', + titleEs: '', + eventId: '', + visibility: 'private' as GalleryVisibility, + }); + + useEffect(() => { + Promise.all([photosApi.getGalleries(), eventsApi.getAll()]) + .then(([g, e]) => { + setGalleries(g.galleries); + setEvents(e.events); + }) + .catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries')) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.title.trim()) return; + setCreating(true); + try { + const { gallery } = await photosApi.createGallery({ + title: form.title.trim(), + titleEs: form.titleEs.trim() || undefined, + eventId: form.eventId || undefined, + visibility: form.visibility, + }); + setGalleries((prev) => [gallery, ...prev]); + setShowCreate(false); + setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' }); + toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create gallery'); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( +
+
+ + +
+ +
+ ); + } + + return ( +
+
+

+ {locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'} +

+ +
+ + {galleries.length === 0 ? ( + + +

+ {locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'} +

+

+ {locale === 'es' + ? 'Crea una galería para compartir las fotos de un evento' + : 'Create a gallery to share photos from a past event'} +

+ +
+ ) : ( +
+ {galleries.map((g) => ( + + +
+ {g.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+
+
+

+ {locale === 'es' && g.titleEs ? g.titleEs : g.title} +

+ +
+

+ {g.photoCount} {locale === 'es' ? 'fotos' : 'photos'} + {g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}} +

+
+
+ + ))} +
+ )} + + {showCreate && ( +
+ +
+

+ {locale === 'es' ? 'Nueva Galería' : 'New Gallery'} +

+ +
+
+
+ + setForm({ ...form, title: e.target.value })} + className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray" + placeholder="June Language Exchange" + /> +
+
+ + setForm({ ...form, titleEs: e.target.value })} + className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray" + placeholder="Intercambio de Junio" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 70168eb..940bc2a 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -2,6 +2,26 @@ import { MetadataRoute } from 'next'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface SitemapPhotoGallery { + slug: string; + updatedAt: string; + event?: { slug: string }; +} + +/** Public photo galleries from the photo-api service (public visibility only). */ +async function getPublicPhotoGalleries(): Promise { + try { + const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, { + next: { revalidate: 3600 }, + }); + if (!res.ok) return []; + return ((await res.json()).galleries as SitemapPhotoGallery[]) || []; + } catch { + return []; + } +} interface SitemapEvent { id: string; @@ -41,7 +61,10 @@ async function getIndexableEvents(): Promise { } export default async function sitemap(): Promise { - const events = await getIndexableEvents(); + const [events, photoGalleries] = await Promise.all([ + getIndexableEvents(), + getPublicPhotoGalleries(), + ]); const now = new Date(); // Static pages @@ -88,6 +111,12 @@ export default async function sitemap(): Promise { changeFrequency: 'monthly', priority: 0.6, }, + { + url: `${siteUrl}/photos`, + lastModified: now, + changeFrequency: 'weekly', + priority: 0.6, + }, // Legal pages { url: `${siteUrl}/legal/terms-policy`, @@ -120,5 +149,13 @@ export default async function sitemap(): Promise { }; }); - return [...staticPages, ...eventPages]; + const photoPages: MetadataRoute.Sitemap = photoGalleries.map((g) => ({ + // Event-linked galleries live under the event's URL. + url: g.event ? `${siteUrl}/events/${g.event.slug}/gallery` : `${siteUrl}/photos/${g.slug}`, + lastModified: g.updatedAt ? new Date(g.updatedAt) : now, + changeFrequency: 'monthly' as const, + priority: 0.5, + })); + + return [...staticPages, ...eventPages, ...photoPages]; } diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx new file mode 100644 index 0000000..e9c5e91 --- /dev/null +++ b/frontend/src/components/Lightbox.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useEffect, useCallback, useState } from 'react'; +import { + XMarkIcon, + ChevronLeftIcon, + ChevronRightIcon, + ArrowDownTrayIcon, +} from '@heroicons/react/24/outline'; + +export interface LightboxItem { + id: string; + previewUrl: string; + downloadUrl: string; + filename?: string; +} + +interface LightboxProps { + items: LightboxItem[]; + index: number; + onClose: () => void; + onNavigate: (index: number) => void; + /** Extra per-item action buttons rendered in the top bar (admin use). */ + renderActions?: (item: LightboxItem, index: number) => React.ReactNode; +} + +/** + * Full-screen photo lightbox with keyboard and swipe navigation, in the + * style of the admin gallery preview modal (fixed inset-0 bg-black/90). + */ +export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { + const [touchStartX, setTouchStartX] = useState(null); + const item = items[index]; + + const prev = useCallback(() => { + onNavigate(index > 0 ? index - 1 : items.length - 1); + }, [index, items.length, onNavigate]); + const next = useCallback(() => { + onNavigate(index < items.length - 1 ? index + 1 : 0); + }, [index, items.length, onNavigate]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'ArrowLeft') prev(); + if (e.key === 'ArrowRight') next(); + }; + window.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [onClose, prev, next]); + + if (!item) return null; + + return ( +
setTouchStartX(e.touches[0].clientX)} + onTouchEnd={(e) => { + if (touchStartX === null) return; + const dx = e.changedTouches[0].clientX - touchStartX; + if (dx > 60) prev(); + if (dx < -60) next(); + setTouchStartX(null); + }} + > + + +
e.stopPropagation()} + > + + + + {renderActions?.(item, index)} +
+ + {items.length > 1 && ( + <> + + + + )} + + {/* eslint-disable-next-line @next/next/no-img-element */} + e.stopPropagation()} + draggable={false} + /> + +
+ {index + 1} / {items.length} +
+ + {/* Preload neighbours so prev/next feels instant. */} +
+ {items.length > 1 && ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + )} +
+
+ ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 13efe12..b697e9a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -275,6 +275,7 @@ "contacts": "Messages", "emails": "Emails", "gallery": "Gallery", + "photos": "Photos", "settings": "Settings" }, "events": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 6c28fce..63446c3 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -275,6 +275,7 @@ "contacts": "Mensajes", "emails": "Emails", "gallery": "Galería", + "photos": "Fotos", "settings": "Configuración" }, "events": { diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index bc3c4c2..54eb58e 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings'; export { legalSettingsApi } from './legalSettings'; export { legalPagesApi } from './legalPages'; export { faqApi } from './faq'; +export { photosApi } from './photos'; +export type { + Photo, + PhotoGallery, + PhotoGalleryEvent, + GalleryVisibility, + CreateGalleryInput, +} from './photos'; diff --git a/frontend/src/lib/api/photos.ts b/frontend/src/lib/api/photos.ts new file mode 100644 index 0000000..9fc7da3 --- /dev/null +++ b/frontend/src/lib/api/photos.ts @@ -0,0 +1,176 @@ +import { fetchApi, API_BASE, getToken } from './client'; + +// Client for the standalone photo-api Go service (photo-api/), reachable +// under /api/photos via the Next rewrite (dev) or nginx (prod). + +export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket'; + +export interface PhotoGalleryEvent { + id: string; + slug: string; + title: string; + titleEs?: string; + startDatetime: string; + status: string; +} + +export interface PhotoGallery { + id: string; + slug: string; + title: string; + titleEs?: string; + description?: string; + descriptionEs?: string; + eventId?: string; + visibility: GalleryVisibility; + coverPhotoId?: string; + photoCount: number; + coverUrl?: string; + createdAt: string; + updatedAt: string; + event?: PhotoGalleryEvent; + // Present on admin responses only: + shareToken?: string; + shareUrl?: string; +} + +export interface Photo { + id: string; + galleryId: string; + position: number; + originalFilename?: string; + contentType: string; + sizeBytes: number; + width?: number; + height?: number; + takenAt?: string; + status: 'queued' | 'processing' | 'ready' | 'failed'; + lastError?: string; + createdAt: string; + urls: { + thumb?: string; + preview?: string; + original: string; + }; +} + +export interface CreateGalleryInput { + title: string; + titleEs?: string; + description?: string; + descriptionEs?: string; + eventId?: string; + visibility?: GalleryVisibility; +} + +export const photosApi = { + // Admin (requires admin/organizer token) + createGallery: (data: CreateGalleryInput) => + fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', { + method: 'POST', + body: JSON.stringify(data), + }), + + getGalleries: (eventId?: string) => { + const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''; + return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`); + }, + + getGallery: (id: string) => + fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`), + + updateGallery: (id: string, data: Partial & { coverPhotoId?: string }) => + fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + + deleteGallery: (id: string) => + fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }), + + rotateShareToken: (id: string) => + fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, { + method: 'POST', + }), + + uploadPhoto: async (galleryId: string, file: File) => { + const token = getToken(); + const formData = new FormData(); + formData.append('files', file); + + const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({ error: 'Upload failed' })); + throw new Error(errorData.error || 'Upload failed'); + } + return res.json() as Promise<{ photos: Photo[] }>; + }, + + /** + * Upload with byte-level progress (0..1). fetch() cannot report upload + * progress, so this uses XMLHttpRequest; used by the admin uploader panel. + */ + uploadPhotoWithProgress: ( + galleryId: string, + file: File, + onProgress: (fraction: number) => void + ) => + new Promise<{ photos: Photo[] }>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`); + const token = getToken(); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total); + }; + xhr.onload = () => { + try { + const body = JSON.parse(xhr.responseText || '{}'); + if (xhr.status >= 200 && xhr.status < 300) resolve(body); + else reject(new Error(body.error || `Upload failed (${xhr.status})`)); + } catch { + reject(new Error(`Upload failed (${xhr.status})`)); + } + }; + xhr.onerror = () => reject(new Error('Network error during upload')); + xhr.onabort = () => reject(new Error('Upload cancelled')); + const formData = new FormData(); + formData.append('files', file); + xhr.send(formData); + }), + + reorderPhotos: (galleryId: string, photoIds: string[]) => + fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, { + method: 'PATCH', + body: JSON.stringify({ photoIds }), + }), + + deletePhoto: (photoId: string) => + fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }), + + retryPhoto: (photoId: string) => + fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }), + + // Viewer (anonymous or member token; share token via query param) + getPublicGalleries: () => + fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'), + + getPublicGallery: (slug: string, token?: string) => { + const query = token ? `?token=${encodeURIComponent(token)}` : ''; + return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>( + `/api/photos/public/galleries/${encodeURIComponent(slug)}${query}` + ); + }, + + /** Newest gallery linked to an event; backs /events/[slug]/gallery. */ + getEventGallery: (eventSlug: string, token?: string) => { + const query = token ? `?token=${encodeURIComponent(token)}` : ''; + return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>( + `/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}` + ); + }, +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 7b28589..2d5e51c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,19 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next-build/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/package.json b/package.json index 57e81b3..66cbe08 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,16 @@ "private": true, "description": "Spanglish - Language Exchange Event Platform", "scripts": { - "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\" \"npm run dev:photos\"", "dev:backend": "npm run dev --workspace=backend", "dev:frontend": "npm run dev --workspace=frontend", + "dev:photos": "cd photo-api && go run ./cmd/photo-api", "build": "npm run build --workspaces", "build:backend": "npm run build --workspace=backend", "build:frontend": "npm run build --workspace=frontend", + "build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api", + "test:photos": "cd photo-api && go test ./...", + "migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "start:backend": "npm run start --workspace=backend", "start:frontend": "npm run start --workspace=frontend", diff --git a/photo-api/.env.example b/photo-api/.env.example new file mode 100644 index 0000000..e1ea7b8 --- /dev/null +++ b/photo-api/.env.example @@ -0,0 +1,43 @@ +# Spanglish photo-api configuration +# Copy to .env — the service loads it on start (systemd also passes it via +# EnvironmentFile). This service has its own .env; nothing is shared with +# backend/.env except the VALUES of JWT_SECRET and DATABASE_URL. + +# Port (dev default 3003; the systemd unit overrides to 3020 in production) +PORT=3003 + +# Database — the SAME database the backend uses. photo-api owns only the +# photos_* tables (created by `photo-api migrate`) and reads users/events/ +# tickets/payments for auth and access checks. +# DB_TYPE must match the backend's engine: postgres | sqlite +DB_TYPE=postgres +DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db +# For sqlite instead: +# DB_TYPE=sqlite +# DATABASE_URL=../backend/data/spanglish.db + +# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the +# same HS256 tokens the backend issues. +JWT_SECRET= + +# Public site origin, used to build share links and allow dev CORS +FRONTEND_URL=https://spanglishcommunity.com + +# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and +# S3_BUCKET switches to S3 (same convention as the backend's media storage). +# Use a photos-specific bucket — do not reuse the backend's media bucket. +STORAGE_PATH=./data/photos +#S3_ENDPOINT= +#S3_REGION=auto +#S3_BUCKET=spanglish-photos +#S3_ACCESS_KEY_ID= +#S3_SECRET_ACCESS_KEY= +#S3_FORCE_PATH_STYLE=true + +# Upload and processing tuning +MAX_UPLOAD_MB=50 +WORKER_CONCURRENCY=2 + +# HEIC conversion shells out to a host-installed CLI (autodetects `vips` +# then `heif-convert`; Debian: apt install libvips-tools). Set to override. +#HEIC_CONVERTER= diff --git a/photo-api/Dockerfile b/photo-api/Dockerfile new file mode 100644 index 0000000..a55cb59 --- /dev/null +++ b/photo-api/Dockerfile @@ -0,0 +1,25 @@ +# Secondary deployment path (the primary is bare-metal systemd, see +# deploy/spanglish-photos.service). Built for the docker-compose "scale" +# setup or any container host. +# +# docker build -t spanglish-photo-api photo-api/ +# docker run --env-file photo-api/.env -p 3020:3020 spanglish-photo-api + +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /photo-api ./cmd/photo-api + +# debian-slim rather than distroless: HEIC conversion needs the libvips CLI. +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-tools ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /photo-api /usr/local/bin/photo-api +RUN useradd -r -u 10001 photoapi && mkdir -p /data/photos && chown photoapi /data/photos +USER photoapi +ENV STORAGE_PATH=/data/photos +EXPOSE 3020 +ENTRYPOINT ["/usr/local/bin/photo-api"] diff --git a/photo-api/Makefile b/photo-api/Makefile new file mode 100644 index 0000000..6fcfefb --- /dev/null +++ b/photo-api/Makefile @@ -0,0 +1,23 @@ +.PHONY: start build test migrate clean help + +BINARY := bin/photo-api +CMD := ./cmd/photo-api + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' + +start: ## Run the photo-api server (go run) + go run $(CMD) + +build: ## Build binary to bin/photo-api + go build -o $(BINARY) $(CMD) + +test: ## Run all Go tests + go test ./... + +migrate: ## Apply pending photos_* migrations + go run $(CMD) migrate + +clean: ## Remove built binary + rm -rf bin diff --git a/photo-api/PLAN.md b/photo-api/PLAN.md new file mode 100644 index 0000000..fb002eb --- /dev/null +++ b/photo-api/PLAN.md @@ -0,0 +1,334 @@ +# Photo API — Implementation Plan + +Event photo galleries for the Spanglish platform, built as a standalone Go service in `photo-api/`. +Written 2026-07-24 after a read-only investigation of the repo. + +> **Revised 2026-07-24 after review.** Decisions from the review that amend the sections below: +> 1. **Both Postgres and SQLite are supported from day 1**, mirroring the backend's `DB_TYPE`/`DATABASE_URL` convention. Consequence: the dedicated `photos` Postgres schema is dropped (SQLite has no schemas); instead every table carries a **`photos_` name prefix** in both engines, which still cannot collide with any Drizzle-owned table name. Migrations are dual-dialect (`*.pg.sql` / `*.sqlite.sql`), SQLite driver is `modernc.org/sqlite` (pure Go, keeps the cgo-free static binary), and queue claiming uses `FOR UPDATE SKIP LOCKED` on Postgres and a plain claim-UPDATE on SQLite. +> 2. Deployment stays **systemd-first with the Dockerfile as the secondary path** (confirmed). +> 3. **Free events**: any `confirmed`/`checked_in` ticket grants access when the event's `price = 0`; paid events still require a `payments.status='paid'` row (§7 SQL updated). +> 4. Admin nav keeps the name **"Photos"** alongside the existing "Gallery" item (confirmed). +> 5. **HEIC is supported**, not rejected: uploads sniffed as HEIC are converted to JPEG for the variant pipeline by shelling out to a host-installed converter (`vips` from libvips-tools, falling back to `heif-convert`); the Go binary stays cgo-free and the original `.heic` is stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged. +> 6. Photo admin surface is **`admin`/`organizer` only** (confirmed). +> 7. **Local disk and S3 both supported from day 1** (was already planned). +> 8. The service has **its own `photo-api/.env`** (was already planned). + +--- + +## 0. Investigation summary (what the codebase actually is) + +**Database.** Drizzle schema lives in one file, `backend/src/db/schema.ts`, with *dual* SQLite/Postgres table definitions selected at runtime by `DB_TYPE` (bottom-of-file conditional exports, lines ~716–738). `drizzle.config.ts` + `npm run db:generate` exist, but the migration that actually runs is the hand-written idempotent script `backend/src/db/migrate.ts` (`npm run db:migrate`), which is **not** run automatically at server start. The local `backend/.env` sets `DB_TYPE=postgres` with `DATABASE_URL=postgresql://spanglish:...@localhost:5432/spanglish_db` (driver: `pg` via `drizzle-orm/node-postgres`, everything in the `public` schema). Relevant tables: + +- `events`: `id` (uuid in PG), `slug`, bilingual title/description fields, `start_datetime`, `status` (`draft|published|unlisted|cancelled|completed|archived`), `banner_url`, … +- `tickets`: `id`, `booking_id`, `user_id`, `event_id`, attendee fields, `status` (`pending|confirmed|cancelled|checked_in|on_hold`), `is_guest`, … +- `payments`: one row per ticket (`ticket_id` FK), `status` (`pending|pending_approval|paid|refunded|failed|cancelled|on_hold`), `provider` (`bancard|lightning|cash|bank_transfer|tpago`), `paid_at`. + +**A ticket is "paid" when its `payments` row has `status='paid'`** (set in `backend/src/routes/payments.ts` on admin approval/PATCH, and in `backend/src/routes/lnbits.ts` for Lightning), at which point the ticket itself is set to `confirmed`. "Paid" lives on the payment, not the ticket. + +**Auth.** Stateless JWT, library `jose`, **HS256** with the shared symmetric secret `JWT_SECRET`, issuer `spanglish`, audience `spanglish-app`, 1-day expiry (`backend/src/lib/auth.ts`, `createToken` lines 236–246). Payload: `{ sub: userId, email, role, tokenVersion, iat, exp }`. Transport is `Authorization: Bearer `; the frontend stores it in `localStorage` under `spanglish-token` (`frontend/src/lib/api/client.ts`). Roles live in a single `users.role` column: `admin|organizer|staff|marketing|user`; enforcement is `requireAuth(roles?)` (`auth.ts:332`), and media/admin routes use `['admin','organizer']`. Revocation is DB-backed: `users.token_version` must match the token's `tokenVersion` claim, and `account_status` must be `active`. **A Go service can validate tokens independently** with `JWT_SECRET` + HS256 + iss/aud checks; honoring revocation additionally needs one indexed read of `users`. (There is a `user_sessions` table but it is a session *list*, not the auth mechanism.) + +**Backend conventions.** Hono on Node (`@hono/node-server`), entry `backend/src/index.ts`. One Hono sub-router per domain in `src/routes/*`, mounted under `/api/`; shared helpers in `src/lib/*`; no controller/service layering. Validation: zod via `@hono/zod-validator`. Config: `dotenv/config` + ad-hoc `process.env.*` with inline defaults, no env schema. Responses: success is the resource keyed by name (`{ media, url }`, `{ contacts: [...] }`) or `{ message }`; errors are `{ error: string }` with the status as the second arg; central `app.onError` → `{ error: 'Internal Server Error' }` and `app.notFound` → `{ error: 'Not Found' }`. + +**Existing media code (do not touch).** `backend/src/routes/media.ts` (mounted at `/api/media`): `POST /api/media/upload` (auth `['admin','organizer']`, Hono `parseBody`, magic-byte sniffing, UUID filename), plus GET/DELETE/list. Storage abstraction `backend/src/lib/storage.ts`: local `./uploads` flat dir (served at `/uploads/*` from `index.ts:1877`) or S3 when `S3_ENDPOINT` + `S3_BUCKET` are set (`S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`). One generic `media` table (`id, file_url, type, related_id, related_type, created_at`). **No image resizing anywhere** (no sharp). Note: despite the feature name, there is no gallery *table* or backend gallery feature — `frontend/src/app/admin/gallery/page.tsx` is an admin page that uploads via `/api/media` with `relatedType: 'gallery'`. Event flyers/banners are just `events.banner_url` strings pointing at `/uploads/...`. + +**Frontend.** Next.js **14.2** App Router (`frontend/src/app`), React 18, TS 5.5. Admin shell + nav: `frontend/src/app/admin/layout.tsx` with a `navigationWithRoles` array (Heroicons); protection = `middleware.ts` checking a non-authoritative `spanglish-auth=1` cookie + client-side `useAuth()` guard; the API is the real gate. API calls: `fetchApi` wrapper in `src/lib/api/client.ts` (`API_BASE = NEXT_PUBLIC_API_URL || ''`, Bearer from localStorage); per-domain modules in `src/lib/api/*`. Same-origin `/api/*` and `/uploads/*` are rewritten to `BACKEND_URL` in `next.config.js`. Data fetching: server components with `revalidate` for public SEO pages handing off to `*Client.tsx`; plain `useEffect` in admin (no react-query). Tailwind 3.4 with custom theme (`primary.yellow #FBB82B`, `brand.navy`, Poppins, `rounded-card` 16px, `shadow-card`); custom UI primitives in `src/components/ui/` (`Button`, `Card`, `Input`, `Skeleton` incl. `ImageGridSkeleton`); no shadcn. Custom i18n: `src/i18n/` (en/es JSON) + `LanguageContext` with `t()`. No public gallery page exists today; there is a homepage carousel fed from `public/images/carrousel/`. + +**Infra.** Real deployment is **bare-metal systemd + host nginx**, not Docker: `deploy/spanglish-backend.service` (node `dist/index.js`, port 3018, `EnvironmentFile=backend/.env`, hardened with `ProtectSystem=strict` + `ReadWritePaths` for `uploads`/`data`) and `deploy/spanglish-frontend.service` (`next start -p 3019`). Host nginx (`deploy/front-end_nginx.conf`, `back-end_nginx.conf`, `spanglish_upstreams.conf`) terminates TLS (Let's Encrypt) and routes `/api` and `/uploads` → backend :3018, everything else → frontend :3019; `client_max_body_size 20m`. `deploy/docker-compose.scale.yml` is a documented-as-non-turnkey alternative that references a `backend/Dockerfile` **which does not exist**. **There is no CI at all** (no `.github/`). Secrets come from gitignored per-service `.env` files referenced by `EnvironmentFile=`. + +### Where your description and the repo disagree (flagged, not worked around) + +1. **"Shared Postgres"** — the backend is dual-engine and *defaults* to SQLite. The local `backend/.env` says `DB_TYPE=postgres`, so this plan assumes production is Postgres, but I cannot verify the production host's env from the repo. **If production actually runs SQLite, the DB and ticket-check parts of this plan change materially** (see Risks). +2. **"Its own container"** — nothing in this repo is deployed in a container; there are zero Dockerfiles and the compose file is explicitly a non-production starting point. The plan makes the systemd path primary (a static Go binary fits it perfectly) and includes a Dockerfile so the service *can* run as a container in the compose-scale setup. +3. **"backend/ already has gallery … logic"** — there is no gallery model in the backend; only the generic `media` table and an admin frontend page. The collision surface is smaller than described: it's the `/api/media` routes, the `/uploads` path/dir, and the `S3_*`/`MEDIA_MAX_UPLOAD_MB` env names. This plan avoids all of them. +4. **Ticket "paid" status** lives on the `payments` table, not the ticket — the check must join both. + +--- + +## 1. Architecture + +A single Go 1.26 binary (`photo-api`) exposing HTTP under the path prefix **`/api/photos`**, sitting behind the same host nginx, sharing the existing Postgres **database** but owning its own **schema** (`photos`), and validating the existing HS256 JWTs itself. + +Why this shape: + +- **Same-origin path prefix instead of a new subdomain.** The frontend already reaches the backend via same-origin `/api/*` (nginx in prod, `next.config.js` rewrites in dev). Adding one more-specific nginx `location /api/photos/` and one more-specific Next rewrite keeps `fetchApi` working unchanged — no CORS, no new cert, no new env in the browser. Backend mounts (`index.ts:1901-1916`) include no `/api/photos` route, so there is no collision. +- **Shared DB, own schema.** The ticket-visibility feature *requires* reading `tickets`/`payments`/`users`; that data lives in `spanglish_db`. Putting photo tables in a `photos` schema in the same database gives one-query access checks, one backup story, and zero drizzle-kit interaction (drizzle introspects/manages only the tables it defines, all in `public`; it never touches other schemas). +- **Systemd-first deployment**, mirroring `deploy/spanglish-backend.service`: a new `spanglish-photos.service` running a static binary on port **3020** (prod; 3002 in dev, following the 3018/3019 prod vs 3001/3000 dev split). +- **Stdlib `net/http`** with Go 1.22+ method/pattern routing (`mux.HandleFunc("GET /api/photos/...")`). The backend's Hono style — one router per domain, per-route middleware, `{ error }` JSON — maps cleanly onto this without importing a framework. Boring, zero-dep, matches "small service". +- **Same response conventions as Hono backend**: resource-keyed success bodies (`{ gallery }`, `{ galleries }`, `{ photos }`), `{ error: "..." }` failures, `Authorization: Bearer` auth, roles `['admin','organizer']` for admin surface — so the frontend treats it exactly like another backend domain module. + +Request path: browser → nginx (`location ^~ /api/photos/`) → photo-api :3020 → Postgres (`photos.*` for its data, read-only use of `public.users/tickets/payments/events` for auth + access checks) → local disk or S3 for bytes. + +## 2. Database: schema and migrations + +**Owned by the Go service. drizzle-kit never sees it.** All photo tables carry a `photos_` name prefix (`photos_galleries`, `photos_photos`, `photos_schema_migrations`) in the same database the backend uses — Postgres or SQLite per `DB_TYPE` — created and migrated by embedded, versioned, dual-dialect SQL files (`migrations/NNNN_*.pg.sql` + `NNNN_*.sqlite.sql`) applied by a ~60-line runner inside the binary (`photo-api migrate` subcommand). This matches the repo's existing convention — the backend also ignores drizzle-kit for application and runs a hand-written migration script manually at deploy time. The runner: + +- on Postgres takes `pg_advisory_lock` on a fixed key (safe if two instances start); SQLite's file lock covers the single-host case, +- creates `photos_schema_migrations (version int primary key, applied_at)`, +- applies each migration in a transaction, recording versions. + +It is invoked by systemd as `ExecStartPre=/…/photo-api migrate` so deploys stay one-step, while Drizzle's tables are never touched. On SQLite the service opens the same database file as the backend (`WAL` is already enabled by the backend; photo-api sets `busy_timeout` and keeps write transactions short). + +`migrations/0001_init.pg.sql` (the `.sqlite.sql` twin mirrors the backend's SQLite conventions: `text` ids generated in Go, `text` RFC3339 timestamps, `integer` booleans): + +```sql +CREATE TABLE photos_galleries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug varchar(160) NOT NULL UNIQUE, + title varchar(200) NOT NULL, + title_es varchar(200), + description text, + description_es text, + event_id uuid, -- references public.events(id); soft reference, no FK (see note) + visibility varchar(10) NOT NULL DEFAULT 'private' + CHECK (visibility IN ('public','private','link','ticket')), + share_token varchar(64) NOT NULL UNIQUE, -- 32 random bytes, base64url; used only for 'link' mode + cover_photo_id uuid, -- set after photos exist; soft reference to photos_photos + created_by uuid, -- public.users(id); soft reference + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX photos_galleries_event_idx ON photos_galleries (event_id); +CREATE INDEX photos_galleries_visibility_idx ON photos_galleries (visibility); + +CREATE TABLE photos_photos ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + original_key text NOT NULL, -- storage key of the untouched original + original_filename text, + content_type varchar(50) NOT NULL, + size_bytes bigint NOT NULL, + width integer, + height integer, + thumb_key text, -- set when variant is ready + preview_key text, + taken_at timestamptz, -- from EXIF when present + status varchar(12) NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','processing','ready','failed')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position); +CREATE INDEX photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed'); +``` + +**No foreign keys into Drizzle-owned tables** (`events`/`users`), deliberately: an FK from `photos_*` into a Drizzle-owned table would make future Drizzle migrations (drop/recreate, type changes) fail in confusing ways — exactly the "two tools fighting" scenario. Referential integrity for `event_id` is enforced in the handler (verify the event exists on create/update); a dangling `event_id` degrades gracefully (ticket-gated gallery just denies access; event card omitted). + +The photo rows themselves are the processing queue (`status/attempts/next_attempt_at`) — no separate jobs table, no Redis. Queue claiming: `FOR UPDATE SKIP LOCKED` on Postgres, plain claim-UPDATE on SQLite (single host there anyway). Query text uses `?` placeholders rebound to `$n` for Postgres by a small helper; ids are generated in Go (`uuid`), timestamps bound as Go `time.Time` (timestamptz on PG, RFC3339 text on SQLite, matching the backend's dual-schema style). + +## 3. API surface + +All routes under `/api/photos`. Auth = `Authorization: Bearer `. "Admin" = role `admin` or `organizer` (mirrors `backend/src/routes/media.ts`). Errors are `{ error: string }` with 400/401/403/404/413/500. + +### Admin (JWT, role admin|organizer) + +| Method & path | Purpose | Response | +|---|---|---| +| `POST /api/photos/galleries` | Create gallery. Body: `{ title, titleEs?, description?, descriptionEs?, eventId?, visibility }`. Slug generated from title (suffix on collision), `share_token` generated always. | `201 { gallery }` | +| `GET /api/photos/galleries` | List all galleries (any visibility) with photo counts, cover thumb URL. Query: `eventId?`, `limit/offset`. | `{ galleries: [...] }` | +| `GET /api/photos/galleries/:id` | Full gallery detail incl. photos in position order with per-variant URLs and processing status. | `{ gallery, photos: [...] }` | +| `PATCH /api/photos/galleries/:id` | Update title/description/eventId/visibility/coverPhotoId. | `{ gallery }` | +| `DELETE /api/photos/galleries/:id` | Delete gallery + photos + stored objects. | `{ message }` | +| `POST /api/photos/galleries/:id/photos` | Multipart upload, field `files` (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit `415 { error: "HEIC is not supported, please upload JPEG" }` for HEIC). Stores original, inserts row `status='queued'`, appends at end position. | `201 { photos: [...] }` | +| `PATCH /api/photos/galleries/:id/order` | Body `{ photoIds: [uuid,...] }` — full ordering; positions rewritten in one transaction. | `{ message }` | +| `DELETE /api/photos/photos/:photoId` | Delete one photo + its objects; compacts positions. | `{ message }` | +| `POST /api/photos/galleries/:id/share-token` | Rotate share token (invalidate old links). | `{ gallery }` | +| `POST /api/photos/photos/:photoId/retry` | Re-queue a `failed` photo. | `{ photo }` | + +The share link the admin copies is a frontend URL: `https://spanglishcommunity.com/photos/?token=`. + +### Viewer (public / optional JWT) + +| Method & path | Purpose | Response | +|---|---|---| +| `GET /api/photos/public/galleries` | Public index: `visibility='public'` only, with cover thumb + count + linked-event summary. | `{ galleries: [...] }` | +| `GET /api/photos/public/galleries/:slug?token=` | Single gallery incl. `ready` photos in order. Access check per matrix (§7); optional Bearer honored. | `{ gallery, photos: [...] }` or 401/403/404 | +| `GET /api/photos/files/:photoId/:variant?token=` | The bytes. `variant ∈ thumb|preview|original`. Same access check as its gallery. Local: streamed with long-lived `Cache-Control` (keyed URLs are stable); S3: 302 to a presigned GET (15 min). `original` adds `Content-Disposition: attachment; filename=""`. | image bytes / 302 | +| `GET /api/photos/health` | Liveness (also mapped at `/health` on the port for systemd checks). | `{ status: "ok" }` | + +Photo objects in responses carry ready-to-use URLs (`/api/photos/files//thumb` etc., with `?token=` appended by the client when in link mode) so the frontend never constructs storage paths. + +## 4. `photo-api/` layout and module setup + +``` +photo-api/ + go.mod # module github.com/Michilis/spanglish/photo-api + .env.example + Dockerfile # secondary path; multi-stage, static binary, distroless + cmd/photo-api/main.go # flag/subcommand: serve (default) | migrate + migrations/ # embedded SQL (embed.FS) + 0001_init.sql + internal/config/config.go # env parsing with defaults, mirrors backend's ad-hoc style but centralized + internal/auth/auth.go # JWT verify (HS256, iss/aud), tokenVersion+account_status check, role helpers + internal/store/ # pgx queries: galleries.go, photos.go, access.go (ticket check), migrate.go + internal/storage/ # storage.go (interface), local.go, s3.go + internal/imaging/ # decode, EXIF orient, resize, encode JPEG + internal/worker/worker.go # variant-processing loop + internal/httpapi/ # router.go, middleware.go, galleries.go, photos_upload.go, files.go, public.go, respond.go +``` + +**Plain `go.mod`, no `go.work`.** There is exactly one Go module and no cross-module Go imports, so a workspace file adds nothing. Dependencies (each justified, all boring): + +- `github.com/jackc/pgx/v5` (via its `stdlib` adapter) — canonical Postgres driver; advisory locks. +- `modernc.org/sqlite` — pure-Go SQLite driver (no cgo), for `DB_TYPE=sqlite` parity with the backend. +- `github.com/golang-jwt/jwt/v5` — HS256 verification of the existing tokens. +- `github.com/aws/aws-sdk-go-v2` (s3 + presign) — same SDK family the backend uses for S3; needed for the S3 storage backend and presigned URLs. +- `golang.org/x/image` — high-quality scaling (`draw.CatmullRom`). +- `github.com/rwcarlsen/goexif` — EXIF orientation + `taken_at`. (Tiny, stable, read-only.) + +**Monorepo integration.** npm workspaces stay `["backend","frontend"]` — a Go dir cannot be a workspace and nothing should pretend it is. Root `package.json` gets convenience scripts only: + +```json +"dev:photos": "cd photo-api && go run ./cmd/photo-api", +"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api", +"test:photos": "cd photo-api && go test ./..." +``` + +and root `dev` adds the third `concurrently` entry. `.gitignore` additions: `photo-api/bin/`, `photo-api/data/`, (`.env` already covered). There is **no CI in this repo**, so nothing to wire; if CI is added later, path-filter Go steps on `photo-api/**`. Note: `pnpm-lock.yaml` sits untracked next to the committed `package-lock.json` — unrelated to this feature but worth resolving; scripts above are package-manager-agnostic. + +## 5. Storage abstraction + +```go +type Storage interface { + Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error + Open(ctx context.Context, key string) (io.ReadCloser, int64, error) // local streaming + Delete(ctx context.Context, key string) error + // PresignGet returns ("", ErrNoPresign) on local storage; handler then streams via Open. + PresignGet(ctx context.Context, key, downloadFilename string, expiry time.Duration) (string, error) +} +``` + +Key layout (both backends): `galleries//orig/.`, `.../thumb/.jpg`, `.../preview/.jpg`. + +- **`local.go`** — root dir from `STORAGE_PATH` (default `./data/photos`), `0600` files, atomic write via temp+rename. **Not** `backend/uploads` — completely disjoint tree, and nothing is nginx-static-served: all reads go through the access-checked `/api/photos/files/` handler. +- **`s3.go`** — same selection convention as `backend/src/lib/storage.ts`: S3 active when `S3_ENDPOINT` and `S3_BUCKET` are both set; honors `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` (default true, Garage/MinIO-friendly). Serving uses presigned GETs so private galleries stay private even on S3 — the bucket is never public. + +Env var names deliberately reuse the backend's vocabulary (`S3_*`, `DATABASE_URL`, `JWT_SECRET`, `PORT`) because each systemd service loads its **own** `EnvironmentFile` (`photo-api/.env`) — same conventions, zero collision. The one shared-by-value var is `JWT_SECRET` (must equal the backend's) and `DATABASE_URL` (same DB). Recommend a *different* `S3_BUCKET` (e.g. `spanglish-photos`) than any backend bucket. Full key list in §9. + +## 6. Image processing + +**Library: pure Go for the pipeline, external CLI only for HEIC decode** — stdlib `image/jpeg|png|gif`, `golang.org/x/image` (webp decode, CatmullRom scaler), `goexif`. Reasoning: the real deployment is a hardened systemd unit on a bare host; govips/bimg would add a libvips system dependency, cgo, and non-static binaries, complicating both the host install and the (secondary) container image for a service whose write volume is "admin uploads a batch after an event", not a hot path. Pure Go costs ~1–3 s/photo per variant on large JPEGs — fine for an async queue. + +- **HEIC (required per review): decoded by shelling out**, since no viable pure-Go HEVC decoder exists. The worker converts HEIC→JPEG (quality 95, temp file in the service's data dir) via the first available of: `HEIC_CONVERTER` (explicit path/command), `vips copy` (Debian package `libvips-tools`), `heif-convert` (package `libheif-examples`), then feeds the JPEG into the normal pure-Go pipeline. The Go binary itself stays cgo-free/static; the converter is a runtime host dependency (added to the systemd host setup and the Dockerfile — which therefore uses `debian:bookworm-slim` rather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original `.heic` is stored byte-identical and served for download with its real content type. The sniffer recognizes `ftyp` brands `heic|heix|hevc|hevx|mif1|msf1`. +- AVIF: accepted at upload only if decodable; stdlib has no AVIF decoder, so in practice AVIF is rejected with an explicit message (can ride the same converter escape hatch later if needed). + +**Variants** (originals stored byte-identical, never touched): + +| Variant | Long edge | Format | Quality | Purpose | +|---|---|---|---|---| +| `thumb` | 512 px | JPEG | 78 | grid | +| `preview` | 2048 px | JPEG | 85 | lightbox | +| `original` | — | as uploaded | — | download | + +Re-encoding variants strips EXIF (including GPS — a privacy win for published pages); orientation is applied to pixels first. `width/height/taken_at` recorded on the row from the original. + +**Queue/retry:** the `photos.photos` row is the job. Worker = single goroutine pool (size `WORKER_CONCURRENCY`, default 2) polling with `SELECT ... WHERE status IN ('queued','failed') AND attempts < 5 AND next_attempt_at <= now() ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED`, plus an in-process channel nudge after each upload so the poll interval (10 s) is only a fallback. On error: `attempts+1`, exponential `next_attempt_at`, `last_error`; after 5 attempts stays `failed` and the admin UI shows a retry button (`/photos/:id/retry`). `FOR UPDATE SKIP LOCKED` makes multi-instance safe for free. Crash recovery: on startup, rows stuck in `processing` older than 10 min are re-queued. + +## 7. Access control matrix + +Caller types: **anon** (no/invalid JWT), **member** (valid JWT, any role), **ticket-holder** (member with paid ticket for the gallery's linked event), **link-holder** (anyone presenting the gallery's `share_token`), **admin** (JWT role `admin|organizer`). + +| Visibility | anon | member | ticket-holder | link-holder | admin | +|---|---|---|---|---|---| +| `public` | ✅ view/download | ✅ | ✅ | ✅ | ✅ + manage | +| `private` | ❌ 404 | ❌ 404 | ❌ 404 | ❌ 404 (token ignored) | ✅ + manage | +| `link` | ❌ 404 without token | ❌ 404 without token | ❌ 404 without token | ✅ with valid token | ✅ + manage | +| `ticket` | ❌ 401 | ❌ 403 | ✅ | valid token also grants ✅ (link is a superset escape hatch; token exists on every gallery but is only honored for `link` and `ticket` modes) | ✅ + manage | + +- Listing: only `public` galleries ever appear in `/api/photos/public/galleries`. `link`/`ticket`/`private` are unlisted; non-public misses return **404, not 403**, so gallery existence isn't probeable (exception: `ticket` mode returns 401/403 so the frontend can prompt login/purchase — the gallery's existence is intentionally discoverable via its event page). +- Enforcement lives in **one function**, `store.Authorize(ctx, gallery, caller, token)`, called in exactly two places: the gallery-view handler and the **file handler** — every byte served re-checks; there are no capability URLs except the explicit share token, and S3 presigned URLs are short-lived (15 min) so a leaked presign expires. +- **Ticket check** (the `ticket` row + `payments` join, per how "paid" actually works here): + +```sql +SELECT 1 +FROM tickets t +JOIN events e ON e.id = t.event_id +LEFT JOIN payments p ON p.ticket_id = t.id +WHERE t.user_id = ? AND t.event_id = ? + AND t.status IN ('confirmed','checked_in') + AND (p.status = 'paid' OR e.price = 0) -- review decision: free events need only a confirmed ticket +LIMIT 1; +``` + +Chosen over the alternative (forwarding the user's JWT to the backend's existing `GET /api/dashboard/tickets`, `backend/src/routes/dashboard.ts:98`) because: we are already in the same database for our own tables, it is one indexed query instead of an HTTP hop + JSON parse, it has no runtime dependency on the backend being up, and it needs zero backend changes. The cost is read-coupling to four `public` columns (`tickets.user_id/event_id/status`, `payments.ticket_id/status`) — mitigated by confining every `public.*` query to `internal/store/access.go` and a startup sanity check that those columns exist (fail loud, not wrong). +- **JWT verification** in Go: HS256 + `iss=spanglish` + `aud=spanglish-app`, then one `SELECT role, token_version, account_status FROM public.users WHERE id=$1` to enforce the same revocation semantics as `getAuthUser` (`backend/src/lib/auth.ts:320-327`). Skipping the DB check would silently weaken logout-everywhere/suspension for photo routes; since we're in the DB anyway, we match the backend exactly. + +## 8. Frontend work + +New files (all following existing patterns — `fetchApi`, `useLanguage().t`, Tailwind theme, `ui/` primitives): + +- `src/lib/api/photos.ts` — API module (types + calls for every endpoint in §3), re-exported from `src/lib/api/index.ts`. +- **Admin** — `src/app/admin/photos/page.tsx`: gallery list (cards: cover thumb, title, visibility badge, event, count; create-gallery modal). `src/app/admin/photos/[id]/page.tsx`: single gallery manager — multi-file upload (input + drag-drop, per-file progress, `status` polling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists via `PATCH .../order`), visibility selector, event linker (reuses events list API), copy-share-link button (`navigator.clipboard`, `react-hot-toast`), set-cover, delete, retry-failed. Modeled on `admin/gallery/page.tsx` + `admin/events/[id]/` structurally. +- **Public** — `src/app/(public)/photos/page.tsx`: server component (fetch public index, `revalidate: 300`, metadata) → `PhotosIndexClient.tsx` grid. `src/app/(public)/photos/[slug]/page.tsx`: server component fetching the gallery *without* token (public galleries get SSR/SEO; link/ticket galleries fall through) → `GalleryClient.tsx` which re-fetches client-side with `?token=` from `useSearchParams()` and the Bearer token, and renders: masonry-ish thumb grid (`ImageGridSkeleton` while loading), lightbox (new shared `src/components/Lightbox.tsx` — full-screen `fixed inset-0 bg-black/90`, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal in `admin/gallery/page.tsx`), download-original per photo, ticket-mode gates (401 → login redirect with `?redirect=`, 403 → "this gallery is for attendees" message linking the event). +- i18n: new `photos.*` and `admin.nav.photos` keys in `src/i18n/locales/{en,es}.json`. + +Existing files changed (complete list): + +| File | Change | +|---|---| +| `src/app/admin/layout.tsx` | one entry in `navigationWithRoles`: `{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin','organizer'] }` (CameraIcon, since PhotoIcon is taken by the existing Gallery item) | +| `next.config.js` | prepend rewrite `{ source: '/api/photos/:path*', destination: PHOTO_API_URL + '/api/photos/:path*' }` **before** the generic `/api/:path*` rule (rewrites are order-sensitive); `PHOTO_API_URL` default `http://localhost:3003` | +| `src/lib/api/index.ts` | re-export `photos` module | +| `src/i18n/locales/en.json`, `es.json` | new keys | +| `src/app/sitemap.ts` | add `/photos` + public gallery slugs (nice-to-have, phase 7) | + +Naming note: the admin nav will then contain both **"Gallery"** (existing homepage-media page at `/admin/gallery`) and **"Photos"** (new). Requirement says "Photos"; flagged in Questions since admins may find the pair confusing. + +## 9. Deployment and config + +New files in `deploy/` (mirroring existing ones; no existing file is *replaced* — the two nginx confs get additive location blocks): + +- `deploy/spanglish-photos.service` — copy of the backend unit shape: user `spanglish`, `ExecStartPre=…/photo-api/bin/photo-api migrate`, `ExecStart=…/photo-api/bin/photo-api`, `EnvironmentFile=…/photo-api/.env`, `PORT=3020`, hardening incl. `ReadWritePaths=…/photo-api/data`. +- `deploy/spanglish_upstreams.conf` — add `upstream spanglish_photos { server 127.0.0.1:3020; }`. +- `deploy/front-end_nginx.conf` — add, above `location /api`: `location ^~ /api/photos/ { proxy_pass http://spanglish_photos; client_max_body_size 100m; proxy_request_buffering off; }` (uploads are big; the vhost default is 20m). Same block in `deploy/back-end_nginx.conf` if the api subdomain should serve it too. +- `photo-api/Dockerfile` — secondary/container path: `golang:1.26` build stage (`CGO_ENABLED=0`), `gcr.io/distroless/static` runtime; and a commented `photos` service example for `deploy/docker-compose.scale.yml` (compose file itself untouched until that path is actually used). + +`photo-api/.env.example`: + +```bash +PORT=3003 # dev; systemd unit overrides to 3020 +DB_TYPE=postgres # postgres | sqlite — must match the backend's engine +DATABASE_URL=postgresql://... # SAME database as backend (photos_* tables created by migrate); + # for sqlite: path to the backend's db file, e.g. ../backend/data/spanglish.db +JWT_SECRET= # MUST equal backend/.env JWT_SECRET +HEIC_CONVERTER= # optional; default autodetects `vips`, then `heif-convert` +STORAGE_PATH=./data/photos # local storage root (ignored when S3 is on) +S3_ENDPOINT= # + S3_BUCKET both set => S3 backend (same convention as backend) +S3_REGION=auto +S3_BUCKET= # use a photos-specific bucket +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_FORCE_PATH_STYLE=true +MAX_UPLOAD_MB=50 # per file +WORKER_CONCURRENCY=2 +FRONTEND_URL=https://spanglishcommunity.com # for building share links +``` + +Frontend `.env` gains `PHOTO_API_URL=http://localhost:3003` (dev; prod nginx routes directly so the rewrite is unused there, matching how `/api` works today). + +## 10. Build sequence + +1. **Scaffold + DB.** `go.mod`, config, `cmd/photo-api` with `serve`/`migrate`, migration runner + `0001_init.sql`, `/health`, root `package.json` scripts. *Test: `photo-api migrate` creates `photos.*` against the dev DB; `psql \dn` shows the schema; re-run is a no-op; `drizzle-kit generate` still produces no diff.* +2. **Auth + gallery CRUD.** JWT middleware (incl. `token_version` check), all `/galleries` admin endpoints. *Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips.* +3. **Upload + local storage + worker.** Multipart handler, sniffing, `LocalStorage`, imaging pipeline, queue/retry, `files/:id/:variant` for admins. *Test: upload a batch of real photos incl. a portrait-orientation iPhone JPEG (EXIF rotation) and a deliberately corrupt file; variants appear; corrupt file lands in `failed` with `last_error`; retry works.* +4. **Access control + public endpoints.** `Authorize`, ticket-check query, `public/galleries`, `public/galleries/:slug`, token handling, presign-vs-stream in file handler. *Test: matrix in §7 as a Go table test against seeded DB rows (paid/pending/refunded payments), plus curl for each caller type.* +5. **S3 backend.** `S3Storage` + presigned GETs, verified against a local MinIO/Garage. *Test: same upload/download flows with `S3_*` set; private gallery file URL fails without presign.* +6. **Admin frontend.** `/admin/photos` pages, nav item, api module, i18n. *Test: create → upload → reorder → visibility → copy link, in-browser against dev services.* +7. **Public frontend.** `/photos`, `/photos/[slug]`, Lightbox, download, gated states, sitemap. *Test: all four visibility modes in-browser, incl. link URL in an incognito window and ticket mode with a paid vs pending test user.* +8. **Deploy.** systemd unit, nginx blocks, `.env.example`s, Dockerfile, README section in `photo-api/`. *Test: on the host — `systemctl start spanglish-photos`, nginx reload, end-to-end over HTTPS.* + +## 11. Risks and open questions + +**Risks / could-not-determine:** + +- **Production DB engine is unverifiable from the repo** (local `backend/.env` says `DB_TYPE=postgres`; prod env is gitignored). Mitigated by the review decision to support both engines — whichever prod runs, photo-api points at the same database with matching `DB_TYPE`. On SQLite, two processes share one file: WAL is already on, photo-api adds `busy_timeout` and short write transactions, but write contention under heavy simultaneous booking + photo-processing load is a residual (small) risk. +- **Schema read-coupling.** The ticket/user queries read five `public` columns Drizzle owns. A future rename breaks photo access checks at runtime; mitigated by the startup column sanity-check (fail fast) and by the queries living in one file. +- **Free events.** If a PYG-0 event issues `confirmed` tickets whose payment row is not `'paid'` (e.g. no payment or `cash`/`pending`), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes into `payments`. May need `OR (e.price = 0 AND t.status IN ('confirmed','checked_in'))`. +- **Guest attendees** (`tickets.is_guest`, tickets bought by someone else): access is keyed on `tickets.user_id`, so a +1 without their own account cannot pass `ticket` gating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge. +- **HEIC depends on a host-installed converter** (`vips` or `heif-convert`). If the prod host lacks the package, HEIC uploads 415 until it's installed; startup logs a warning so this is visible, and the deploy notes include the `apt install libvips-tools` step. +- **Nginx body size / timeouts** for 100 MB batch uploads over spotty Paraguayan mobile connections; `proxy_request_buffering off` planned, but real-world testing is in phase 8. +- **1-day JWT expiry with no working refresh flow** (refresh tokens are minted but no endpoint consumes them) means long admin upload sessions can hit 401 mid-batch; the admin UI must surface that as "session expired, log in again" rather than a generic failure. + +**Questions — answered in review 2026-07-24** (see the revision note at the top): dual Postgres+SQLite support; systemd-first confirmed; free events count any confirmed ticket; nav name "Photos" kept; HEIC supported via converter shell-out; `admin`/`organizer` only; local disk and S3 both supported from day 1; the service keeps its own `.env`. The production engine check (and, if Postgres, connectivity for the `spanglish` role) is still on the user's side. diff --git a/photo-api/README.md b/photo-api/README.md new file mode 100644 index 0000000..624e6f9 --- /dev/null +++ b/photo-api/README.md @@ -0,0 +1,92 @@ +# photo-api + +Standalone Go service for event photo galleries: admins upload photos from +past events, group them into galleries, and share them with the community. +Lives in this monorepo but is its own module, binary, and deployable unit. +Design/decisions: [PLAN.md](./PLAN.md). + +## What it does + +- Galleries with four visibility modes: `public` (listed on /photos), + `private` (admins only), `link` (share-token URL), `ticket` (logged-in + users with a confirmed ticket for the linked event — any confirmed ticket + when the event is free). +- Originals are stored byte-identical for download; a worker generates JPEG + variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation + applied) queued in the DB with retries. +- Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT` + + `S3_BUCKET`), same selection convention as the backend. S3 downloads use + short-lived presigned URLs; nothing in the bucket is public. +- Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET` + (issuer `spanglish`, audience `spanglish-app`) including the DB-backed + tokenVersion/account-status revocation check. Admin surface is + `admin`/`organizer` only. +- Database: the same Postgres or SQLite database as the backend (`DB_TYPE`, + `DATABASE_URL`). This service owns only the `photos_*` tables via its own + embedded migrations (`photo-api migrate`); drizzle-kit never sees them. + Reads of backend tables are confined to `internal/store/access.go` and + sanity-checked at startup. + +## Develop + +```bash +cp .env.example .env # set JWT_SECRET + DATABASE_URL to match backend/.env +go run ./cmd/photo-api migrate +go run ./cmd/photo-api # serves on :3003 (dev) +go test ./... # SQLite; add PHOTO_TEST_PG= to also run on Postgres +``` + +From the repo root: `npm run dev:photos`, `npm run build:photos`, +`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to +`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no +extra config in dev. + +HEIC uploads require a converter CLI on the host: `apt install libvips-tools` +(or `libheif-examples`). Without one, HEIC uploads are rejected with a clear +message and a startup warning is logged. + +## API + +Everything under `/api/photos`. Errors are `{"error": string}`. + +Admin (Bearer token, role admin/organizer): + +| Method | Path | Purpose | +|---|---|---| +| POST | `/api/photos/galleries` | create gallery | +| GET | `/api/photos/galleries` | list all (`?eventId=`) | +| GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) | +| PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover | +| DELETE | `/api/photos/galleries/:id` | delete gallery + objects | +| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`) | +| PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) | +| POST | `/api/photos/galleries/:id/share-token` | rotate share token | +| DELETE | `/api/photos/photos/:photoId` | delete photo | +| POST | `/api/photos/photos/:photoId/retry` | requeue failed photo | + +Viewer (anonymous or member token; `?token=` carries the share token): + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/photos/public/galleries` | public index | +| GET | `/api/photos/public/galleries/:slug` | gallery view (access-checked) | +| GET | `/api/photos/public/events/:eventSlug/gallery` | newest gallery of an event (access-checked) | +| GET | `/api/photos/files/:photoId/:variant` | bytes; `thumb\|preview\|original` | +| GET | `/api/photos/health` | liveness | + +## Deploy (systemd, primary) + +```bash +cd photo-api && go build -o bin/photo-api ./cmd/photo-api +sudo apt install libvips-tools +sudo cp ../deploy/spanglish-photos.service /etc/systemd/system/ +sudo systemctl daemon-reload && sudo systemctl enable --now spanglish-photos +sudo nginx -t && sudo systemctl reload nginx # after installing the updated confs +``` + +nginx routes `location ^~ /api/photos/` → `127.0.0.1:3020` (see +`deploy/front-end_nginx.conf`, `deploy/back-end_nginx.conf`, +`deploy/spanglish_upstreams.conf`). The frontend's production `.env` should +set `PHOTO_API_URL=http://127.0.0.1:3020` for server-side rendering of the +public gallery pages. A `Dockerfile` is included as a secondary path for the +compose-scale setup. diff --git a/photo-api/cmd/photo-api/main.go b/photo-api/cmd/photo-api/main.go new file mode 100644 index 0000000..549a1ee --- /dev/null +++ b/photo-api/cmd/photo-api/main.go @@ -0,0 +1,106 @@ +// photo-api serves event photo galleries for the Spanglish platform. +// +// photo-api start the HTTP server +// photo-api migrate apply pending photos_* migrations and exit +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/httpapi" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("photo-api: ") + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + db, err := store.Open(cfg) + if err != nil { + log.Fatalf("database: %v", err) + } + defer db.Close() + + if len(os.Args) > 1 && os.Args[1] == "migrate" { + if err := db.Migrate(context.Background()); err != nil { + log.Fatalf("migrate: %v", err) + } + log.Println("migrations up to date") + return + } + if len(os.Args) > 1 { + log.Fatalf("unknown subcommand %q (expected: migrate)", os.Args[1]) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := db.PingContext(ctx); err != nil { + log.Fatalf("database ping: %v", err) + } + // Fail fast if the columns read from Drizzle-owned tables changed. + if err := db.CheckMainSchema(ctx); err != nil { + log.Fatalf("%v", err) + } + + st, err := storage.New(cfg) + if err != nil { + log.Fatalf("storage: %v", err) + } + if cfg.S3Enabled() { + log.Printf("storage: S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint) + } else { + log.Printf("storage: local disk at %s", cfg.StoragePath) + } + + heic := imaging.DetectHeicConverter(cfg.HeicConverter) + if heic == nil { + log.Println("WARNING: no HEIC converter found (install libvips-tools or libheif-examples); HEIC uploads will be rejected") + } else { + log.Printf("HEIC converter: %s", heic.Name()) + } + + if err := os.MkdirAll(cfg.StoragePath, 0o755); err != nil { + log.Fatalf("storage path: %v", err) + } + wrk := worker.New(db, st, heic, cfg.StoragePath, cfg.WorkerConcurrency) + wrk.Run(ctx) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + log.Printf("listening on :%d", cfg.Port) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("serve: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } +} diff --git a/photo-api/go.mod b/photo-api/go.mod new file mode 100644 index 0000000..b25826d --- /dev/null +++ b/photo-api/go.mod @@ -0,0 +1,46 @@ +module git.azzamo.net/Michilis/Spanglish/photo-api + +go 1.26 + +require ( + github.com/aws/aws-sdk-go-v2 v1.43.0 + github.com/aws/aws-sdk-go-v2/config v1.32.31 + github.com/aws/aws-sdk-go-v2/credentials v1.19.30 + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd + golang.org/x/image v0.44.0 + modernc.org/sqlite v1.54.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect + github.com/aws/smithy-go v1.27.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.40.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/photo-api/go.sum b/photo-api/go.sum new file mode 100644 index 0000000..aadd85a --- /dev/null +++ b/photo-api/go.sum @@ -0,0 +1,117 @@ +github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= +github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/photo-api/internal/auth/auth.go b/photo-api/internal/auth/auth.go new file mode 100644 index 0000000..ccea98a --- /dev/null +++ b/photo-api/internal/auth/auth.go @@ -0,0 +1,99 @@ +// Package auth validates the JWTs minted by backend/src/lib/auth.ts: +// HS256 with the shared JWT_SECRET, issuer "spanglish", audience +// "spanglish-app", plus the same DB-backed tokenVersion / account_status +// revocation check the backend's getAuthUser performs. +package auth + +import ( + "errors" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +const ( + issuer = "spanglish" + audience = "spanglish-app" +) + +var ( + ErrNoToken = errors.New("no bearer token") + ErrInvalidToken = errors.New("invalid token") +) + +// AdminRoles mirrors backend/src/routes/media.ts: photo administration is +// admin/organizer only (review decision #6). +var AdminRoles = []string{"admin", "organizer"} + +type User struct { + ID string + Email string + Role string +} + +func (u User) IsAdmin() bool { + for _, r := range AdminRoles { + if u.Role == r { + return true + } + } + return false +} + +type claims struct { + Email string `json:"email"` + Role string `json:"role"` + TokenVersion *int `json:"tokenVersion"` + jwt.RegisteredClaims +} + +type Verifier struct { + secret []byte + db *store.DB +} + +func NewVerifier(secret string, db *store.DB) *Verifier { + return &Verifier{secret: []byte(secret), db: db} +} + +// FromRequest returns the authenticated user, ErrNoToken when no +// Authorization header is present, or ErrInvalidToken for anything bad. +func (v *Verifier) FromRequest(r *http.Request) (User, error) { + header := r.Header.Get("Authorization") + if header == "" { + return User{}, ErrNoToken + } + raw, ok := strings.CutPrefix(header, "Bearer ") + if !ok { + return User{}, ErrInvalidToken + } + + var c claims + _, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil }, + jwt.WithValidMethods([]string{"HS256"}), + jwt.WithIssuer(issuer), + jwt.WithAudience(audience), + jwt.WithExpirationRequired(), + ) + if err != nil || c.Subject == "" { + return User{}, ErrInvalidToken + } + + // DB-backed revocation, same semantics as backend getAuthUser + // (backend/src/lib/auth.ts:320-327). + ua, err := v.db.GetUserAuth(r.Context(), c.Subject) + if err != nil { + return User{}, ErrInvalidToken + } + if ua.AccountStatus != "active" { + return User{}, ErrInvalidToken + } + if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion { + return User{}, ErrInvalidToken + } + // Role from the DB, not the token, so demotions apply immediately. + return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil +} diff --git a/photo-api/internal/config/config.go b/photo-api/internal/config/config.go new file mode 100644 index 0000000..0fa0f03 --- /dev/null +++ b/photo-api/internal/config/config.go @@ -0,0 +1,126 @@ +// Package config loads photo-api configuration from the environment, +// following the backend's convention of a per-service .env file with +// ad-hoc defaults (backend/src/index.ts uses dotenv the same way). +package config + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + Port int + DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE + DatabaseURL string + JWTSecret string + + StoragePath string + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKeyID string + S3SecretKey string + S3ForcePathStyle bool + + MaxUploadMB int + WorkerConcurrency int + FrontendURL string + HeicConverter string // optional explicit converter command; autodetected when empty +} + +// S3Enabled mirrors backend/src/lib/storage.ts: S3 is active when both +// S3_ENDPOINT and S3_BUCKET are set. +func (c Config) S3Enabled() bool { + return c.S3Endpoint != "" && c.S3Bucket != "" +} + +func Load() (Config, error) { + loadDotenv(".env") + + cfg := Config{ + Port: envInt("PORT", 3003), + DBType: strings.ToLower(env("DB_TYPE", "sqlite")), + DatabaseURL: env("DATABASE_URL", ""), + JWTSecret: env("JWT_SECRET", ""), + StoragePath: env("STORAGE_PATH", "./data/photos"), + S3Endpoint: env("S3_ENDPOINT", ""), + S3Region: env("S3_REGION", "auto"), + S3Bucket: env("S3_BUCKET", ""), + S3AccessKeyID: env("S3_ACCESS_KEY_ID", ""), + S3SecretKey: env("S3_SECRET_ACCESS_KEY", ""), + S3ForcePathStyle: envBool("S3_FORCE_PATH_STYLE", true), + MaxUploadMB: envInt("MAX_UPLOAD_MB", 50), + WorkerConcurrency: envInt("WORKER_CONCURRENCY", 2), + FrontendURL: strings.TrimRight(env("FRONTEND_URL", "http://localhost:3000"), "/"), + HeicConverter: env("HEIC_CONVERTER", ""), + } + + if cfg.DBType != "postgres" && cfg.DBType != "sqlite" { + return cfg, fmt.Errorf("DB_TYPE must be postgres or sqlite, got %q", cfg.DBType) + } + if cfg.DatabaseURL == "" { + return cfg, fmt.Errorf("DATABASE_URL is required") + } + if cfg.JWTSecret == "" { + return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)") + } + return cfg, nil +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return def +} + +// loadDotenv is a minimal KEY=VALUE loader (comments and blank lines +// ignored, optional surrounding quotes stripped, existing env wins). +func loadDotenv(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] { + val = val[1 : len(val)-1] + } + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, val) + } + } +} diff --git a/photo-api/internal/httpapi/access.go b/photo-api/internal/httpapi/access.go new file mode 100644 index 0000000..e69086e --- /dev/null +++ b/photo-api/internal/httpapi/access.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "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). +type accessDenial struct { + status int + msg string +} + +// authorize is the single access-control decision point (PLAN.md §7); it is +// called from both the gallery-view handler and the file handler so every +// byte served re-checks. +func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, token string) *accessDenial { + if user != nil && user.IsAdmin() { + return nil + } + switch g.Visibility { + case store.VisibilityPublic: + return nil + case store.VisibilityLink: + if token != "" && token == g.ShareToken { + return nil + } + return &accessDenial{http.StatusNotFound, "Gallery not found"} + case store.VisibilityTicket: + // The share token is honored as an escape hatch (e.g. attendee +1s + // without accounts, at the admin's discretion). + if token != "" && token == g.ShareToken { + return nil + } + if user == nil { + return &accessDenial{http.StatusUnauthorized, "Authentication required"} + } + if g.EventID == "" { + return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"} + } + ok, err := s.db.HasPaidTicket(r.Context(), user.ID, g.EventID) + if err != nil || !ok { + return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"} + } + return nil + default: // private + return &accessDenial{http.StatusNotFound, "Gallery not found"} + } +} diff --git a/photo-api/internal/httpapi/api_test.go b/photo-api/internal/httpapi/api_test.go new file mode 100644 index 0000000..525e821 --- /dev/null +++ b/photo-api/internal/httpapi/api_test.go @@ -0,0 +1,504 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + + "image" + "image/color" + "image/jpeg" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" +) + +const testSecret = "test-secret" + +// Seed ids are UUID-formatted because the Postgres dialect uses uuid columns. +const ( + uAdmin = "11111111-1111-1111-1111-111111111111" + uMember = "22222222-2222-2222-2222-222222222222" + uBuyer = "33333333-3333-3333-3333-333333333333" + uPending = "44444444-4444-4444-4444-444444444444" + uFree = "55555555-5555-5555-5555-555555555555" + evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +) + +type testEnv struct { + handler http.Handler + db *store.DB + worker *worker.Worker +} + +// setup migrates a scratch DB (SQLite by default; Postgres when +// PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the +// main app schema that access.go reads, and returns a ready handler. +func setup(t *testing.T) *testEnv { + t.Helper() + dir := t.TempDir() + cfg := config.Config{ + Port: 0, + DBType: "sqlite", + DatabaseURL: filepath.Join(dir, "test.db"), + JWTSecret: testSecret, + StoragePath: filepath.Join(dir, "photos"), + MaxUploadMB: 5, + WorkerConcurrency: 1, + FrontendURL: "http://localhost:3000", + } + if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" { + cfg.DBType = "postgres" + cfg.DatabaseURL = pgURL + } + db, err := store.Open(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if cfg.DBType == "postgres" { + // The scratch Postgres DB persists across tests; start clean. + if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil { + t.Fatal(err) + } + } + if err := db.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + + idType := "text" + if cfg.DBType == "postgres" { + idType = "uuid" + } + seed := []string{ + `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`, + `CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`, + `CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`, + `CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`, + + `INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`, + `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`, + `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`, + `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`, + `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`, + + `INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`, + `INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`, + + `INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`, + `INSERT INTO payments VALUES ('p1','t1','paid')`, + `INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`, + `INSERT INTO payments VALUES ('p2','t2','pending')`, + `INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`, + } + for _, q := range seed { + if _, err := db.Exec(q); err != nil { + t.Fatalf("seed %q: %v", q, err) + } + } + + st, err := storage.New(cfg) + if err != nil { + t.Fatal(err) + } + wrk := worker.New(db, st, nil, cfg.StoragePath, 1) + srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk) + return &testEnv{handler: srv.Handler(), db: db, worker: wrk} +} + +func makeToken(t *testing.T, sub, email, role string) string { + t.Helper() + tv := 0 + claims := jwt.MapClaims{ + "sub": sub, "email": email, "role": role, "tokenVersion": tv, + "iss": "spanglish", "aud": "spanglish-app", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + } + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret)) + if err != nil { + t.Fatal(err) + } + return s +} + +func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + e.handler.ServeHTTP(w, req) + return w +} + +func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatalf("decode %s: %v", w.Body.String(), err) + } + return v +} + +func testJPEG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 800, 600)) + for x := 0; x < 800; x += 10 { + for y := 0; y < 600; y++ { + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestHealth(t *testing.T) { + e := setup(t) + if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 { + t.Fatalf("health: %d", w.Code) + } +} + +func TestAdminGate(t *testing.T) { + e := setup(t) + body := map[string]string{"title": "Test"} + if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 { + t.Fatalf("anon create: want 401, got %d", w.Code) + } + member := makeToken(t, uMember, "m@x.py", "user") + if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 { + t.Fatalf("member create: want 403, got %d", w.Code) + } + unknown := makeToken(t, newID(), "g@x.py", "admin") + if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 { + t.Fatalf("unknown-user token: want 401, got %d", w.Code) + } +} + +type galleryResp struct { + Gallery galleryJSON `json:"gallery"` + Photos []photoJSON `json:"photos"` +} + +func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON { + t.Helper() + w := e.request(t, "POST", "/api/photos/galleries", admin, body) + if w.Code != 201 { + t.Fatalf("create gallery: %d %s", w.Code, w.Body.String()) + } + return decode[galleryResp](t, w).Gallery +} + +func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("files", "test photo.jpg") + fw.Write(file) + mw.Close() + req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+admin) + w := httptest.NewRecorder() + e.handler.ServeHTTP(w, req) + if w.Code != 201 { + t.Fatalf("upload: %d %s", w.Code, w.Body.String()) + } + photos := decode[struct { + Photos []photoJSON `json:"photos"` + }](t, w).Photos + if len(photos) != 1 { + t.Fatalf("want 1 photo, got %d", len(photos)) + } + return photos[0] +} + +// processQueue runs the worker until the photo is ready or failed. +func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + e.worker.Run(ctx) + e.worker.Nudge() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + p, err := e.db.GetPhoto(ctx, photoID) + if err != nil { + t.Fatal(err) + } + if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) { + return p + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("timeout waiting for processing") + return store.Photo{} +} + +func TestUploadProcessAndServe(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"}) + if g.Slug != "fiesta-de-junio" { + t.Fatalf("slug: %q", g.Slug) + } + // Public galleries share a clean URL: the token grants nothing there. + if g.ShareToken == "" { + t.Fatalf("share token missing: %+v", g) + } + if strings.Contains(g.ShareURL, "token=") { + t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL) + } + if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) { + t.Fatalf("public share url: %s", g.ShareURL) + } + + p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) + if p.Status != "queued" || p.ContentType != "image/jpeg" { + t.Fatalf("uploaded photo: %+v", p) + } + done := processQueue(t, e, p.ID) + if done.Status != "ready" { + t.Fatalf("processing failed: %s", done.LastError) + } + if done.Width != 800 || done.Height != 600 { + t.Fatalf("dimensions: %dx%d", done.Width, done.Height) + } + + for _, variant := range []string{"thumb", "preview", "original"} { + w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil) + if w.Code != 200 { + t.Fatalf("%s: %d", variant, w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { + t.Fatalf("%s content type: %s", variant, ct) + } + } + w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil) + if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") { + t.Fatalf("original disposition: %q", cd) + } + + // Bad upload is rejected by magic bytes. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("files", "notes.txt") + fw.Write([]byte("not an image at all")) + mw.Close() + req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+admin) + rec := httptest.NewRecorder() + e.handler.ServeHTTP(rec, req) + if rec.Code != 415 { + t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAccessMatrix(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + member := makeToken(t, uMember, "m@x.py", "user") + buyer := makeToken(t, uBuyer, "b@x.py", "user") + pending := makeToken(t, uPending, "p@x.py", "user") + freeguy := makeToken(t, uFree, "f@x.py", "user") + + get := func(slug, token, bearer string) int { + path := "/api/photos/public/galleries/" + slug + if token != "" { + path += "?token=" + token + } + return e.request(t, "GET", path, bearer, nil).Code + } + + // private + 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 got := get(priv.Slug, "", admin); got != 200 { + t.Errorf("private/admin: want 200, got %d", got) + } + + // 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, "wrong-token", ""); got != 404 { + t.Errorf("link/bad-token: want 404, got %d", got) + } + if got := get(link.Slug, link.ShareToken, ""); got != 200 { + t.Errorf("link/token: want 200, got %d", got) + } + + // ticket, paid event + tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid}) + if got := get(tick.Slug, "", ""); got != 401 { + t.Errorf("ticket/anon: want 401, got %d", got) + } + if got := get(tick.Slug, "", member); got != 403 { + t.Errorf("ticket/no-ticket: want 403, got %d", got) + } + if got := get(tick.Slug, "", pending); got != 403 { + t.Errorf("ticket/pending-payment: want 403, got %d", got) + } + if got := get(tick.Slug, "", buyer); got != 200 { + t.Errorf("ticket/paid: want 200, got %d", got) + } + if got := get(tick.Slug, tick.ShareToken, ""); got != 200 { + t.Errorf("ticket/share-token: want 200, got %d", got) + } + + // ticket, free event: any confirmed ticket counts (review decision) + free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree}) + if got := get(free.Slug, "", freeguy); got != 200 { + t.Errorf("ticket/free-event-confirmed: want 200, got %d", got) + } + if got := get(free.Slug, "", member); got != 403 { + t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got) + } + + // public index lists only public galleries + pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"}) + idx := decode[struct { + Galleries []galleryJSON `json:"galleries"` + }](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil)) + if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID { + t.Errorf("public index: %+v", idx.Galleries) + } + for _, g := range idx.Galleries { + if g.ShareToken != "" { + t.Errorf("public index leaks share token") + } + } +} + +func TestReorderAndVisibilityUpdate(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"}) + jpg := testJPEG(t) + p1 := uploadPhoto(t, e, admin, g.ID, jpg) + p2 := uploadPhoto(t, e, admin, g.ID, jpg) + + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, + map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 { + t.Fatalf("reorder: %d %s", w.Code, w.Body.String()) + } + detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) + if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID { + t.Fatalf("order not applied: %+v", detail.Photos) + } + + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, + map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 { + t.Fatalf("partial reorder: want 400, got %d", w.Code) + } + + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, + map[string]any{"visibility": "banana"}); w.Code != 400 { + t.Fatalf("bad visibility: want 400, got %d", w.Code) + } + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, + 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) + } +} + +func TestEventGalleryRoute(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + buyer := makeToken(t, uBuyer, "b@x.py", "user") + + g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid}) + + // Event-linked galleries share via the event URL. + if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) { + t.Fatalf("share url should use event route: %s", g.ShareURL) + } + + // /public/events/{slug}/gallery obeys the same access rules. + if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 { + t.Fatalf("event gallery anon: want 401, got %d", w.Code) + } + w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil) + if w.Code != 200 { + t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String()) + } + if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID { + t.Fatalf("wrong gallery: %s != %s", got, g.ID) + } + if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 { + t.Fatalf("event gallery share token: want 200, got %d", w.Code) + } + + // Unknown event slug and event without a gallery are both 404. + if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 { + t.Fatalf("unknown event: want 404, got %d", w.Code) + } + if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 { + t.Fatalf("event without gallery: want 404, got %d", w.Code) + } + + // A standalone gallery keeps the /photos share URL; link mode carries + // the token, public mode does not. + solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"}) + if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") { + t.Fatalf("standalone share url: %s", solo.ShareURL) + } + pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree}) + if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") { + t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL) + } +} + +func TestSlugCollision(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) + b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) + if a.Slug == b.Slug { + t.Fatalf("slug collision: %s", a.Slug) + } + if b.Slug != "misma-fiesta-2" { + t.Fatalf("second slug: %s", b.Slug) + } + if a.Visibility != "private" { + t.Fatalf("default visibility: %s", a.Visibility) + } +} diff --git a/photo-api/internal/httpapi/dto.go b/photo-api/internal/httpapi/dto.go new file mode 100644 index 0000000..0fcf53a --- /dev/null +++ b/photo-api/internal/httpapi/dto.go @@ -0,0 +1,153 @@ +package httpapi + +import ( + "context" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// JSON shapes are camelCase like the backend's responses. + +type eventJSON struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + TitleEs string `json:"titleEs,omitempty"` + StartDatetime string `json:"startDatetime"` + Status string `json:"status"` +} + +type galleryJSON struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + TitleEs string `json:"titleEs,omitempty"` + Description string `json:"description,omitempty"` + DescriptionEs string `json:"descriptionEs,omitempty"` + EventID string `json:"eventId,omitempty"` + Visibility string `json:"visibility"` + CoverPhotoID string `json:"coverPhotoId,omitempty"` + PhotoCount int `json:"photoCount"` + CoverURL string `json:"coverUrl,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Event *eventJSON `json:"event,omitempty"` + // Admin-only fields: + ShareToken string `json:"shareToken,omitempty"` + ShareURL string `json:"shareUrl,omitempty"` +} + +type photoURLs struct { + Thumb string `json:"thumb,omitempty"` + Preview string `json:"preview,omitempty"` + Original string `json:"original"` +} + +type photoJSON struct { + ID string `json:"id"` + GalleryID string `json:"galleryId"` + Position int `json:"position"` + OriginalFilename string `json:"originalFilename,omitempty"` + ContentType string `json:"contentType"` + SizeBytes int64 `json:"sizeBytes"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + TakenAt string `json:"takenAt,omitempty"` + Status string `json:"status"` + LastError string `json:"lastError,omitempty"` // admin only + CreatedAt string `json:"createdAt"` + URLs photoURLs `json:"urls"` +} + +func isoTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// fileURL builds the access-checked file endpoint URL; token is appended +// for link-mode viewers so the client can use URLs verbatim. +func fileURL(photoID, variant, token string) string { + u := "/api/photos/files/" + photoID + "/" + variant + if token != "" { + u += "?token=" + token + } + return u +} + +func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON { + out := photoJSON{ + ID: p.ID, + GalleryID: p.GalleryID, + Position: p.Position, + OriginalFilename: p.OriginalFilename, + ContentType: p.ContentType, + SizeBytes: p.SizeBytes, + Width: p.Width, + Height: p.Height, + TakenAt: isoTime(p.TakenAt), + Status: p.Status, + CreatedAt: isoTime(p.CreatedAt), + URLs: photoURLs{Original: fileURL(p.ID, "original", token)}, + } + if p.ThumbKey != "" { + out.URLs.Thumb = fileURL(p.ID, "thumb", token) + } + if p.PreviewKey != "" { + out.URLs.Preview = fileURL(p.ID, "preview", token) + } + if admin { + out.LastError = p.LastError + } + return out +} + +func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON { + out := galleryJSON{ + ID: g.ID, + Slug: g.Slug, + Title: g.Title, + TitleEs: g.TitleEs, + Description: g.Description, + DescriptionEs: g.DescriptionEs, + EventID: g.EventID, + Visibility: g.Visibility, + CoverPhotoID: g.CoverPhotoID, + PhotoCount: g.PhotoCount, + CreatedAt: isoTime(g.CreatedAt), + UpdatedAt: isoTime(g.UpdatedAt), + } + if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" { + out.CoverURL = fileURL(coverID, "thumb", token) + } + if g.EventID != "" { + if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil { + out.Event = &eventJSON{ + ID: ev.ID, + Slug: ev.Slug, + Title: ev.Title, + TitleEs: ev.TitleEs, + StartDatetime: isoTime(ev.StartDatetime), + Status: ev.Status, + } + } + } + if admin { + out.ShareToken = g.ShareToken + // Event-linked galleries live under the event's URL; standalone + // galleries keep their own /photos/ URL. + page := "/photos/" + g.Slug + if out.Event != nil { + page = "/events/" + out.Event.Slug + "/gallery" + } + out.ShareURL = s.cfg.FrontendURL + page + // The token only grants anything in link/ticket mode; public and + // private galleries get a clean URL. + if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket { + out.ShareURL += "?token=" + g.ShareToken + } + } + return out +} diff --git a/photo-api/internal/httpapi/files.go b/photo-api/internal/httpapi/files.go new file mode 100644 index 0000000..2ee44ea --- /dev/null +++ b/photo-api/internal/httpapi/files.go @@ -0,0 +1,117 @@ +package httpapi + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" +) + +const presignExpiry = 15 * time.Minute + +// serveFile delivers photo bytes after re-running the gallery access check. +// S3: 302 to a short-lived presigned URL; local: streamed directly. +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) { + variant := r.PathValue("variant") + if variant != "thumb" && variant != "preview" && variant != "original" { + writeError(w, http.StatusNotFound, "Not Found") + return + } + p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + g, err := s.db.GetGallery(r.Context(), p.GalleryID) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + user := s.optionalUser(r) + token := r.URL.Query().Get("token") + if denial := s.authorize(r, g, user, token); denial != nil { + writeError(w, denial.status, denial.msg) + return + } + isAdmin := user != nil && user.IsAdmin() + + var key, contentType, downloadName string + switch variant { + case "thumb": + key, contentType = p.ThumbKey, "image/jpeg" + case "preview": + key, contentType = p.PreviewKey, "image/jpeg" + case "original": + key, contentType = p.OriginalKey, p.ContentType + downloadName = p.OriginalFilename + if downloadName == "" { + downloadName = p.ID + extForContentType(p.ContentType) + } + } + if key == "" { + // Variant not generated yet (photo still processing). + writeError(w, http.StatusNotFound, "Not ready") + return + } + // Non-ready originals stay admin-only so uploads that fail processing + // never leak to viewers. + if p.Status != "ready" && !isAdmin { + writeError(w, http.StatusNotFound, "Not ready") + return + } + + if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil { + http.Redirect(w, r, url, http.StatusFound) + return + } else if !errors.Is(err, storage.ErrNoPresign) { + log.Printf("Error: presign %s: %v", key, err) + writeError(w, http.StatusInternalServerError, "Internal Server Error") + return + } + + reader, size, err := s.storage.Open(r.Context(), key) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "Not Found") + return + } + log.Printf("Error: open %s: %v", key, err) + writeError(w, http.StatusInternalServerError, "Internal Server Error") + return + } + defer reader.Close() + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("X-Content-Type-Options", "nosniff") + // Keys are immutable (new upload = new key), so private caching is safe + // even though access is checked per request. + w.Header().Set("Cache-Control", "private, max-age=86400") + if downloadName != "" { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName)) + } + if _, err := io.Copy(w, reader); err != nil { + log.Printf("stream %s: %v", key, err) + } +} + +func extForContentType(ct string) string { + switch ct { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "image/heic": + return ".heic" + } + return "" +} diff --git a/photo-api/internal/httpapi/galleries.go b/photo-api/internal/httpapi/galleries.go new file mode 100644 index 0000000..c5c2458 --- /dev/null +++ b/photo-api/internal/httpapi/galleries.go @@ -0,0 +1,225 @@ +package httpapi + +import ( + "log" + "net/http" + "strconv" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +func validVisibility(v string) bool { + switch v { + case store.VisibilityPublic, store.VisibilityPrivate, store.VisibilityLink, store.VisibilityTicket: + return true + } + return false +} + +type createGalleryBody struct { + Title string `json:"title"` + TitleEs string `json:"titleEs"` + Description string `json:"description"` + DescriptionEs string `json:"descriptionEs"` + EventID string `json:"eventId"` + Visibility string `json:"visibility"` +} + +func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth.User) { + var body createGalleryBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + if body.Title == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + if body.Visibility == "" { + body.Visibility = store.VisibilityPrivate + } + if !validVisibility(body.Visibility) { + writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket") + return + } + if body.EventID != "" { + if _, err := s.db.GetEventSummary(r.Context(), body.EventID); err != nil { + writeStoreError(w, err, "Event not found") + return + } + } + + slug, err := s.uniqueSlug(r.Context(), body.Title) + if err != nil { + writeStoreError(w, err, "") + return + } + now := time.Now() + g := store.Gallery{ + ID: newID(), + Slug: slug, + Title: body.Title, + TitleEs: body.TitleEs, + Description: body.Description, + DescriptionEs: body.DescriptionEs, + EventID: body.EventID, + Visibility: body.Visibility, + ShareToken: newShareToken(), + CreatedBy: user.ID, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.db.CreateGallery(r.Context(), g); err != nil { + writeStoreError(w, err, "") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + }) +} + +func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.User) { + limit := queryInt(r, "limit", 100) + offset := queryInt(r, "offset", 0) + galleries, err := s.db.ListGalleries(r.Context(), r.URL.Query().Get("eventId"), limit, offset) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]galleryJSON, 0, len(galleries)) + for _, g := range galleries { + out = append(out, s.galleryToJSON(r.Context(), g, "", true)) + } + writeJSON(w, http.StatusOK, map[string]any{"galleries": out}) +} + +func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User) { + g, err := s.db.GetGallery(r.Context(), r.PathValue("id")) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + photos, err := s.db.ListPhotos(r.Context(), g.ID, false) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]photoJSON, 0, len(photos)) + for _, p := range photos { + out = append(out, s.photoToJSON(p, "", true)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + "photos": out, + }) +} + +type updateGalleryBody struct { + Title *string `json:"title"` + TitleEs *string `json:"titleEs"` + Description *string `json:"description"` + DescriptionEs *string `json:"descriptionEs"` + EventID *string `json:"eventId"` + Visibility *string `json:"visibility"` + CoverPhotoID *string `json:"coverPhotoId"` +} + +func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("id") + var body updateGalleryBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + if body.Title != nil && *body.Title == "" { + writeError(w, http.StatusBadRequest, "title cannot be empty") + return + } + if body.Visibility != nil && !validVisibility(*body.Visibility) { + writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket") + return + } + if body.EventID != nil && *body.EventID != "" { + if _, err := s.db.GetEventSummary(r.Context(), *body.EventID); err != nil { + writeStoreError(w, err, "Event not found") + return + } + } + if body.CoverPhotoID != nil && *body.CoverPhotoID != "" { + p, err := s.db.GetPhoto(r.Context(), *body.CoverPhotoID) + if err != nil || p.GalleryID != id { + writeError(w, http.StatusBadRequest, "coverPhotoId must be a photo of this gallery") + return + } + } + + err := s.db.UpdateGallery(r.Context(), id, store.GalleryUpdate{ + Title: body.Title, + TitleEs: body.TitleEs, + Description: body.Description, + DescriptionEs: body.DescriptionEs, + EventID: body.EventID, + Visibility: body.Visibility, + CoverPhotoID: body.CoverPhotoID, + }) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + g, err := s.db.GetGallery(r.Context(), id) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + }) +} + +func (s *Server) deleteGallery(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("id") + keys, err := s.db.PhotoKeys(r.Context(), id) + if err != nil { + writeStoreError(w, err, "") + return + } + if err := s.db.DeleteGallery(r.Context(), id); err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + // Object cleanup is best-effort after the rows are gone; orphaned + // objects are harmless (unreachable) and logged for manual sweep. + for _, key := range keys { + if err := s.storage.Delete(r.Context(), key); err != nil { + log.Printf("delete object %s: %v", key, err) + } + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Gallery deleted"}) +} + +func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("id") + if err := s.db.RotateShareToken(r.Context(), id, newShareToken()); err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + g, err := s.db.GetGallery(r.Context(), id) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + }) +} + +func queryInt(r *http.Request, key string, def int) int { + if v := r.URL.Query().Get(key); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + } + return def +} diff --git a/photo-api/internal/httpapi/middleware.go b/photo-api/internal/httpapi/middleware.go new file mode 100644 index 0000000..323e105 --- /dev/null +++ b/photo-api/internal/httpapi/middleware.go @@ -0,0 +1,36 @@ +package httpapi + +import ( + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" +) + +// requireAdmin mirrors the backend's requireAuth(['admin','organizer']): +// 401 {"error":"Unauthorized"} without a valid token, 403 +// {"error":"Forbidden"} for valid non-admin users. +func (s *Server) requireAdmin(next func(http.ResponseWriter, *http.Request, auth.User)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, err := s.verifier.FromRequest(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + if !user.IsAdmin() { + writeError(w, http.StatusForbidden, "Forbidden") + return + } + next(w, r, user) + } +} + +// optionalUser returns the authenticated user or nil; an invalid token is +// treated as anonymous rather than an error, matching how public backend +// routes behave. +func (s *Server) optionalUser(r *http.Request) *auth.User { + user, err := s.verifier.FromRequest(r) + if err != nil { + return nil + } + return &user +} diff --git a/photo-api/internal/httpapi/photos.go b/photo-api/internal/httpapi/photos.go new file mode 100644 index 0000000..c230f5d --- /dev/null +++ b/photo-api/internal/httpapi/photos.go @@ -0,0 +1,242 @@ +package httpapi + +import ( + "bytes" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// uploadPhotos accepts multipart form data with one or more "files" parts. +// Each file is sniffed by magic bytes (client filename/Content-Type are +// untrusted, same policy as /api/media/upload), stored as the untouched +// original, and queued for variant processing. +func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) { + galleryID := r.PathValue("id") + g, err := s.db.GetGallery(r.Context(), galleryID) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + + maxFile := int64(s.cfg.MaxUploadMB) << 20 + // Generous request ceiling; nginx enforces its own client_max_body_size. + r.Body = http.MaxBytesReader(w, r.Body, 40*maxFile) + mr, err := r.MultipartReader() + if err != nil { + writeError(w, http.StatusBadRequest, "Expected multipart/form-data") + return + } + + position, err := s.db.NextPosition(r.Context(), g.ID) + if err != nil { + writeStoreError(w, err, "") + return + } + + var created []photoJSON + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + writeError(w, http.StatusBadRequest, "Malformed multipart body") + return + } + if part.FormName() != "files" && part.FormName() != "file" { + part.Close() + continue + } + photo, uploadErr := s.saveUpload(r, g, part, position, maxFile) + part.Close() + if uploadErr != nil { + // One bad file fails the request explicitly rather than silently + // skipping it; the admin UI uploads files individually. + writeError(w, uploadErr.status, uploadErr.msg) + return + } + created = append(created, s.photoToJSON(photo, "", true)) + position++ + } + + if len(created) == 0 { + writeError(w, http.StatusBadRequest, "No file provided") + return + } + s.worker.Nudge() + writeJSON(w, http.StatusCreated, map[string]any{"photos": created}) +} + +type uploadError struct { + status int + msg string +} + +func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) { + head := make([]byte, 16) + n, err := io.ReadFull(part, head) + if err != nil && err != io.ErrUnexpectedEOF { + return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"} + } + head = head[:n] + contentType, ext, ok, reason := imaging.Sniff(head) + if !ok { + return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason} + } + if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil { + return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, + "HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"} + } + + // Spool to a temp file to learn the size before handing to storage + // (S3 wants a length; local rename wants a file anyway). + tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*") + if err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1))) + if err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + if size > maxFile { + return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge, + fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)} + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + + photoID := newID() + key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext) + if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + + now := time.Now() + photo := store.Photo{ + ID: photoID, + GalleryID: g.ID, + Position: position, + OriginalKey: key, + OriginalFilename: sanitizeFilename(part.FileName()), + ContentType: contentType, + SizeBytes: size, + Status: "queued", + NextAttemptAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.db.InsertPhoto(r.Context(), photo); err != nil { + s.storage.Delete(r.Context(), key) + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + return photo, nil +} + +type reorderBody struct { + PhotoIDs []string `json:"photoIds"` +} + +func (s *Server) reorderPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) { + galleryID := r.PathValue("id") + var body reorderBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + existing, err := s.db.ListPhotos(r.Context(), galleryID, false) + if err != nil { + writeStoreError(w, err, "") + return + } + if len(existing) == 0 { + writeStoreError(w, store.ErrNotFound, "Gallery not found or empty") + return + } + current := map[string]bool{} + for _, p := range existing { + current[p.ID] = true + } + if len(body.PhotoIDs) != len(existing) { + writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once") + return + } + seen := map[string]bool{} + for _, id := range body.PhotoIDs { + if !current[id] || seen[id] { + writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once") + return + } + seen[id] = true + } + if err := s.db.ReorderPhotos(r.Context(), galleryID, body.PhotoIDs); err != nil { + writeStoreError(w, err, "") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Order updated"}) +} + +func (s *Server) deletePhoto(w http.ResponseWriter, r *http.Request, _ auth.User) { + p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + if err := s.db.DeletePhoto(r.Context(), p.ID); err != nil { + writeStoreError(w, err, "Photo not found") + return + } + for _, key := range []string{p.OriginalKey, p.ThumbKey, p.PreviewKey} { + if key == "" { + continue + } + if err := s.storage.Delete(r.Context(), key); err != nil { + log.Printf("delete object %s: %v", key, err) + } + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Photo deleted"}) +} + +func (s *Server) retryPhoto(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("photoId") + if err := s.db.RequeuePhoto(r.Context(), id); err != nil { + writeStoreError(w, err, "Photo not found or not failed") + return + } + s.worker.Nudge() + p, err := s.db.GetPhoto(r.Context(), id) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{"photo": s.photoToJSON(p, "", true)}) +} + +func sanitizeFilename(name string) string { + name = filepath.Base(name) + if name == "." || name == "/" { + return "" + } + if len(name) > 200 { + name = name[len(name)-200:] + } + return name +} diff --git a/photo-api/internal/httpapi/public.go b/photo-api/internal/httpapi/public.go new file mode 100644 index 0000000..98743dc --- /dev/null +++ b/photo-api/internal/httpapi/public.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// listPublicGalleries is the public index: only visibility='public' +// galleries ever appear here. +func (s *Server) listPublicGalleries(w http.ResponseWriter, r *http.Request) { + galleries, err := s.db.ListPublicGalleries(r.Context()) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]galleryJSON, 0, len(galleries)) + for _, g := range galleries { + out = append(out, s.galleryToJSON(r.Context(), g, "", false)) + } + writeJSON(w, http.StatusOK, map[string]any{"galleries": out}) +} + +// getPublicGallery serves a single gallery to any authorized viewer. +// ?token= carries the share token for link/ticket modes. +func (s *Server) getPublicGallery(w http.ResponseWriter, r *http.Request) { + g, err := s.db.GetGalleryBySlug(r.Context(), r.PathValue("slug")) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + s.respondGalleryView(w, r, g) +} + +// getEventGallery serves the newest gallery linked to an event, backing the +// /events/{slug}/gallery public page. Same access rules as by-slug. +func (s *Server) getEventGallery(w http.ResponseWriter, r *http.Request) { + ev, err := s.db.GetEventSummaryBySlug(r.Context(), r.PathValue("eventSlug")) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + g, err := s.db.GetGalleryByEventID(r.Context(), ev.ID) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + s.respondGalleryView(w, r, g) +} + +func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g store.Gallery) { + user := s.optionalUser(r) + token := r.URL.Query().Get("token") + if denial := s.authorize(r, g, user, token); denial != nil { + writeError(w, denial.status, denial.msg) + 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 + } + + photos, err := s.db.ListPhotos(r.Context(), g.ID, true) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]photoJSON, 0, len(photos)) + for _, p := range photos { + out = append(out, s.photoToJSON(p, urlToken, false)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, urlToken, false), + "photos": out, + }) +} diff --git a/photo-api/internal/httpapi/respond.go b/photo-api/internal/httpapi/respond.go new file mode 100644 index 0000000..acf2629 --- /dev/null +++ b/photo-api/internal/httpapi/respond.go @@ -0,0 +1,39 @@ +package httpapi + +import ( + "encoding/json" + "log" + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("write response: %v", err) + } +} + +// writeError follows the backend's error shape: {"error": string}. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// writeStoreError maps store errors: ErrNotFound -> 404, else 500 with the +// backend's generic message (details go to the log, not the client). +func writeStoreError(w http.ResponseWriter, err error, notFoundMsg string) { + if err == store.ErrNotFound { + writeError(w, http.StatusNotFound, notFoundMsg) + return + } + log.Printf("Error: %v", err) + writeError(w, http.StatusInternalServerError, "Internal Server Error") +} + +func decodeJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) + dec.DisallowUnknownFields() + return dec.Decode(dst) +} diff --git a/photo-api/internal/httpapi/server.go b/photo-api/internal/httpapi/server.go new file mode 100644 index 0000000..065f186 --- /dev/null +++ b/photo-api/internal/httpapi/server.go @@ -0,0 +1,90 @@ +// Package httpapi exposes the photo-api HTTP surface under /api/photos, +// following the backend's route and response conventions (resource-keyed +// success bodies, {"error": string} failures, Bearer auth). +package httpapi + +import ( + "log" + "net/http" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" +) + +type Server struct { + cfg config.Config + db *store.DB + storage storage.Storage + verifier *auth.Verifier + worker *worker.Worker +} + +func New(cfg config.Config, db *store.DB, st storage.Storage, verifier *auth.Verifier, w *worker.Worker) *Server { + return &Server{cfg: cfg, db: db, storage: st, verifier: verifier, worker: w} +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + health := func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + } + mux.HandleFunc("GET /health", health) + mux.HandleFunc("GET /api/photos/health", health) + + // Admin surface: role admin|organizer only (mirrors /api/media). + mux.HandleFunc("POST /api/photos/galleries", s.requireAdmin(s.createGallery)) + mux.HandleFunc("GET /api/photos/galleries", s.requireAdmin(s.listGalleries)) + mux.HandleFunc("GET /api/photos/galleries/{id}", s.requireAdmin(s.getGallery)) + mux.HandleFunc("PATCH /api/photos/galleries/{id}", s.requireAdmin(s.updateGallery)) + mux.HandleFunc("DELETE /api/photos/galleries/{id}", s.requireAdmin(s.deleteGallery)) + mux.HandleFunc("POST /api/photos/galleries/{id}/photos", s.requireAdmin(s.uploadPhotos)) + mux.HandleFunc("PATCH /api/photos/galleries/{id}/order", s.requireAdmin(s.reorderPhotos)) + mux.HandleFunc("POST /api/photos/galleries/{id}/share-token", s.requireAdmin(s.rotateShareToken)) + mux.HandleFunc("DELETE /api/photos/photos/{photoId}", s.requireAdmin(s.deletePhoto)) + mux.HandleFunc("POST /api/photos/photos/{photoId}/retry", s.requireAdmin(s.retryPhoto)) + + // Viewer surface: optional auth, checked per gallery visibility. + mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries) + mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery) + mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery) + mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile) + + return s.withCommon(mux) +} + +// withCommon adds request logging and a same-spirit CORS allowance as the +// backend's cors() (origin = FRONTEND_URL); in production nginx serves +// same-origin so this mostly matters in development. +func (s *Server) withCommon(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" && origin == s.cfg.FrontendURL { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} diff --git a/photo-api/internal/httpapi/slug.go b/photo-api/internal/httpapi/slug.go new file mode 100644 index 0000000..fb686a1 --- /dev/null +++ b/photo-api/internal/httpapi/slug.go @@ -0,0 +1,72 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + "unicode" + + "github.com/google/uuid" +) + +// slugify follows the spirit of backend/src/lib/slugify.ts: lowercase, +// ASCII-fold common Spanish characters, dashes for everything else. +func slugify(s string) string { + replacer := strings.NewReplacer( + "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u", "ü", "u", "ñ", "n", + "Á", "a", "É", "e", "Í", "i", "Ó", "o", "Ú", "u", "Ü", "u", "Ñ", "n") + s = replacer.Replace(strings.ToLower(strings.TrimSpace(s))) + var b strings.Builder + prevDash := true // avoids a leading dash + for _, r := range s { + switch { + case unicode.IsLetter(r) && r < 128, unicode.IsDigit(r): + b.WriteRune(r) + prevDash = false + default: + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if len(out) > 140 { + out = strings.Trim(out[:140], "-") + } + if out == "" { + out = "gallery" + } + return out +} + +// uniqueSlug appends -2, -3, ... until the slug is free. +func (s *Server) uniqueSlug(ctx context.Context, title string) (string, error) { + base := slugify(title) + slug := base + for i := 2; ; i++ { + exists, err := s.db.SlugExists(ctx, slug) + if err != nil { + return "", err + } + if !exists { + return slug, nil + } + slug = fmt.Sprintf("%s-%d", base, i) + } +} + +func newID() string { + return uuid.NewString() +} + +// newShareToken returns 32 random bytes, base64url (43 chars, no padding). +func newShareToken() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + panic(err) // crypto/rand failure is unrecoverable + } + return base64.RawURLEncoding.EncodeToString(buf) +} diff --git a/photo-api/internal/imaging/heic.go b/photo-api/internal/imaging/heic.go new file mode 100644 index 0000000..97ae156 --- /dev/null +++ b/photo-api/internal/imaging/heic.go @@ -0,0 +1,60 @@ +package imaging + +import ( + "fmt" + "os/exec" +) + +// HEIC cannot be decoded in pure Go (HEVC), so conversion shells out to a +// host-installed CLI. The Go binary itself stays cgo-free; the converter is +// a runtime dependency (Debian: libvips-tools or libheif-examples). +type HeicConverter struct { + kind string // "vips" | "heif-convert" | "custom" + path string +} + +// DetectHeicConverter honors an explicit HEIC_CONVERTER command, else +// autodetects vips then heif-convert. Returns nil when none is available; +// uploads of HEIC are then rejected with a clear message. +func DetectHeicConverter(explicit string) *HeicConverter { + if explicit != "" { + if p, err := exec.LookPath(explicit); err == nil { + return &HeicConverter{kind: "custom", path: p} + } + return nil + } + if p, err := exec.LookPath("vips"); err == nil { + return &HeicConverter{kind: "vips", path: p} + } + if p, err := exec.LookPath("heif-convert"); err == nil { + return &HeicConverter{kind: "heif-convert", path: p} + } + return nil +} + +func (h *HeicConverter) Name() string { return h.path } + +// ToJPEG converts src (a .heic file) to a JPEG at dst. +func (h *HeicConverter) ToJPEG(src, dst string) error { + var cmd *exec.Cmd + switch h.kind { + case "vips": + cmd = exec.Command(h.path, "copy", src, dst+"[Q=95]") + case "heif-convert": + cmd = exec.Command(h.path, "-q", "95", src, dst) + default: + // custom converter contract: + cmd = exec.Command(h.path, src, dst) + } + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("heic conversion failed: %v: %s", err, truncate(string(out), 300)) + } + return nil +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/photo-api/internal/imaging/imaging.go b/photo-api/internal/imaging/imaging.go new file mode 100644 index 0000000..15c4162 --- /dev/null +++ b/photo-api/internal/imaging/imaging.go @@ -0,0 +1,159 @@ +// Package imaging produces the two derived variants from an original: +// thumb (512px long edge, JPEG q78) for the grid and preview (2048px long +// edge, JPEG q85) for the lightbox. Originals are never modified. +// Re-encoding strips EXIF (including GPS) from variants; orientation is +// applied to pixels first. +package imaging + +import ( + "fmt" + "image" + "image/jpeg" + "io" + "os" + "time" + + _ "image/gif" + _ "image/png" + + "github.com/rwcarlsen/goexif/exif" + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +const ( + ThumbLongEdge = 512 + ThumbQuality = 78 + PreviewLongEdge = 2048 + PreviewQuality = 85 +) + +type Result struct { + Width int // of the original, after orientation + Height int + TakenAt time.Time // zero when EXIF has no usable timestamp +} + +// ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be +// converted to JPEG first), applies EXIF orientation, and writes both +// variants as JPEG to thumbPath and previewPath. +func ProcessFile(path, thumbPath, previewPath string) (Result, error) { + f, err := os.Open(path) + if err != nil { + return Result{}, err + } + orientation, takenAt := readExif(f) + if _, err := f.Seek(0, io.SeekStart); err != nil { + f.Close() + return Result{}, err + } + img, _, err := image.Decode(f) + f.Close() + if err != nil { + return Result{}, fmt.Errorf("decode: %w", err) + } + + img = applyOrientation(img, orientation) + bounds := img.Bounds() + res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt} + + if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil { + return res, fmt.Errorf("preview: %w", err) + } + if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil { + return res, fmt.Errorf("thumb: %w", err) + } + return res, nil +} + +func writeVariant(img image.Image, path string, longEdge, quality int) error { + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality}) +} + +// resize scales so the long edge is at most longEdge, never upscaling. +func resize(img image.Image, longEdge int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + long := max(w, h) + if long <= longEdge { + return img + } + scale := float64(longEdge) / float64(long) + nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)) + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) + return dst +} + +func readExif(r io.Reader) (orientation int, takenAt time.Time) { + orientation = 1 + x, err := exif.Decode(r) + if err != nil { + return orientation, takenAt + } + if tag, err := x.Get(exif.Orientation); err == nil { + if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 { + orientation = v + } + } + if t, err := x.DateTime(); err == nil { + takenAt = t + } + return orientation, takenAt +} + +// applyOrientation bakes the EXIF orientation into pixels (values 2-8 per +// the EXIF spec; 1 is identity). +func applyOrientation(img image.Image, orientation int) image.Image { + switch orientation { + case 2: + return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y }) + case 3: + return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y }) + case 4: + return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y }) + case 5: + return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x }) + case 6: + return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x }) + case 7: + return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x }) + case 8: + return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x }) + default: + return img + } +} + +// transform maps destination (x,y) to source coordinates, same dimensions. +func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sx, sy := srcAt(x, y, w, h) + dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) + } + } + return dst +} + +// transformSwap is for orientations that swap width and height. +func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + dst := image.NewRGBA(image.Rect(0, 0, h, w)) + for y := 0; y < w; y++ { + for x := 0; x < h; x++ { + sx, sy := srcAt(x, y, w, h) + dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) + } + } + return dst +} diff --git a/photo-api/internal/imaging/sniff.go b/photo-api/internal/imaging/sniff.go new file mode 100644 index 0000000..847f657 --- /dev/null +++ b/photo-api/internal/imaging/sniff.go @@ -0,0 +1,30 @@ +package imaging + +import "bytes" + +// Sniff identifies an image by magic bytes, ignoring the client-supplied +// filename and Content-Type — same policy as backend/src/routes/media.ts +// detectImageType. Returns the canonical content type and extension, or +// ok=false with a human-readable reason. +func Sniff(head []byte) (contentType, ext string, ok bool, reason string) { + switch { + case len(head) >= 3 && bytes.Equal(head[:3], []byte{0xFF, 0xD8, 0xFF}): + return "image/jpeg", ".jpg", true, "" + case len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): + return "image/png", ".png", true, "" + case len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))): + return "image/gif", ".gif", true, "" + case len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")): + return "image/webp", ".webp", true, "" + } + if len(head) >= 12 && bytes.Equal(head[4:8], []byte("ftyp")) { + brand := string(head[8:12]) + switch brand { + case "heic", "heix", "hevc", "hevx", "mif1", "msf1": + return "image/heic", ".heic", true, "" + case "avif", "avis": + return "", "", false, "AVIF is not supported, please upload JPEG, PNG, WebP, GIF or HEIC" + } + } + return "", "", false, "unsupported file type, please upload JPEG, PNG, WebP, GIF or HEIC" +} diff --git a/photo-api/internal/storage/local.go b/photo-api/internal/storage/local.go new file mode 100644 index 0000000..02f9168 --- /dev/null +++ b/photo-api/internal/storage/local.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// local stores objects under root (STORAGE_PATH, default ./data/photos). +// Deliberately disjoint from backend/uploads; nothing here is ever served +// statically — all reads go through the access-checked files handler. +type local struct { + root string +} + +func newLocal(root string) (*local, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create storage dir %s: %w", root, err) + } + return &local{root: root}, nil +} + +// path validates the key stays inside root (keys are generated internally, +// but defense-in-depth costs one Clean call). +func (l *local) path(key string) (string, error) { + clean := filepath.Clean("/" + key) + if strings.Contains(clean, "..") { + return "", fmt.Errorf("invalid key %q", key) + } + return filepath.Join(l.root, clean), nil +} + +func (l *local) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) error { + dst, err := l.path(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), dst) +} + +func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error) { + p, err := l.path(key) + if err != nil { + return nil, 0, err + } + f, err := os.Open(p) + if err != nil { + return nil, 0, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, 0, err + } + return f, info.Size(), nil +} + +func (l *local) Delete(_ context.Context, key string) error { + p, err := l.path(key) + if err != nil { + return err + } + err = os.Remove(p) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (l *local) PresignGet(context.Context, string, string, string, time.Duration) (string, error) { + return "", ErrNoPresign +} diff --git a/photo-api/internal/storage/s3.go b/photo-api/internal/storage/s3.go new file mode 100644 index 0000000..ca05239 --- /dev/null +++ b/photo-api/internal/storage/s3.go @@ -0,0 +1,92 @@ +package storage + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" +) + +// s3Store targets AWS S3 or path-style compatibles (Garage/MinIO), same as +// the backend's S3 backend. The bucket is never public: downloads use +// short-lived presigned GETs so visibility rules keep holding on S3. +type s3Store struct { + client *s3.Client + presign *s3.PresignClient + bucket string +} + +func newS3(cfg config.Config) (*s3Store, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(cfg.S3Region), + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.S3AccessKeyID, cfg.S3SecretKey, "")), + ) + if err != nil { + return nil, fmt.Errorf("s3 config: %w", err) + } + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.S3Endpoint) + o.UsePathStyle = cfg.S3ForcePathStyle + }) + return &s3Store{ + client: client, + presign: s3.NewPresignClient(client), + bucket: cfg.S3Bucket, + }, nil +} + +func (s *s3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: r, + ContentLength: aws.Int64(size), + ContentType: aws.String(contentType), + }) + return err +} + +func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) { + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, 0, err + } + return out.Body, aws.ToInt64(out.ContentLength), nil +} + +func (s *s3Store) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + return err +} + +func (s *s3Store) PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) { + in := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + } + if contentType != "" { + in.ResponseContentType = aws.String(contentType) + } + if downloadFilename != "" { + in.ResponseContentDisposition = aws.String(fmt.Sprintf("attachment; filename=%q", downloadFilename)) + } + req, err := s.presign.PresignGetObject(ctx, in, s3.WithPresignExpires(expiry)) + if err != nil { + return "", err + } + return req.URL, nil +} diff --git a/photo-api/internal/storage/storage.go b/photo-api/internal/storage/storage.go new file mode 100644 index 0000000..37e4b01 --- /dev/null +++ b/photo-api/internal/storage/storage.go @@ -0,0 +1,32 @@ +// Package storage abstracts photo object storage. Selection follows the +// backend's convention (backend/src/lib/storage.ts): S3 when S3_ENDPOINT +// and S3_BUCKET are both set, local disk otherwise. Keys are identical on +// both backends: galleries///.. +package storage + +import ( + "context" + "errors" + "io" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" +) + +// ErrNoPresign is returned by backends that cannot presign (local disk); +// callers then stream the object through the API instead. +var ErrNoPresign = errors.New("presigned URLs not supported") + +type Storage interface { + Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error + Open(ctx context.Context, key string) (io.ReadCloser, int64, error) + Delete(ctx context.Context, key string) error + PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) +} + +func New(cfg config.Config) (Storage, error) { + if cfg.S3Enabled() { + return newS3(cfg) + } + return newLocal(cfg.StoragePath) +} diff --git a/photo-api/internal/store/access.go b/photo-api/internal/store/access.go new file mode 100644 index 0000000..9db6bd5 --- /dev/null +++ b/photo-api/internal/store/access.go @@ -0,0 +1,113 @@ +package store + +// Every query against Drizzle-owned tables (users, events, tickets, +// payments) lives in this file so the read-coupling surface stays small +// and auditable. Column semantics follow backend/src/db/schema.ts. + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +type UserAuth struct { + ID string + Role string + TokenVersion int + AccountStatus string +} + +func (db *DB) GetUserAuth(ctx context.Context, userID string) (UserAuth, error) { + row := db.QueryRowContext(ctx, db.Rebind( + "SELECT id, role, token_version, account_status FROM users WHERE id = ?"), userID) + var id, role, tv, status any + if err := row.Scan(&id, &role, &tv, &status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return UserAuth{}, ErrNotFound + } + return UserAuth{}, err + } + return UserAuth{ + ID: asString(id), + Role: asString(role), + TokenVersion: int(asInt(tv)), + AccountStatus: asString(status), + }, nil +} + +// HasPaidTicket implements the review decision: a confirmed/checked-in +// ticket whose payment is 'paid', or any confirmed/checked-in ticket when +// the event is free (price = 0). +func (db *DB) HasPaidTicket(ctx context.Context, userID, eventID string) (bool, error) { + var v any + err := db.QueryRowContext(ctx, db.Rebind(` + SELECT 1 + FROM tickets t + JOIN events e ON e.id = t.event_id + LEFT JOIN payments p ON p.ticket_id = t.id + WHERE t.user_id = ? AND t.event_id = ? + AND t.status IN ('confirmed','checked_in') + AND (p.status = 'paid' OR e.price = 0) + LIMIT 1`), userID, eventID).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +type EventSummary struct { + ID string + Slug string + Title string + TitleEs string + StartDatetime time.Time + Status string +} + +func (db *DB) GetEventSummary(ctx context.Context, eventID string) (EventSummary, error) { + return db.getEventWhere(ctx, "id = ?", eventID) +} + +// GetEventSummaryBySlug resolves the /events/{slug}/gallery public route. +func (db *DB) GetEventSummaryBySlug(ctx context.Context, slug string) (EventSummary, error) { + return db.getEventWhere(ctx, "slug = ?", slug) +} + +func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSummary, error) { + row := db.QueryRowContext(ctx, db.Rebind( + "SELECT id, slug, title, title_es, start_datetime, status FROM events WHERE "+where), arg) + var id, slug, title, titleEs, start, status any + if err := row.Scan(&id, &slug, &title, &titleEs, &start, &status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return EventSummary{}, ErrNotFound + } + return EventSummary{}, err + } + return EventSummary{ + ID: asString(id), + Slug: asString(slug), + Title: asString(title), + TitleEs: asString(titleEs), + StartDatetime: asTime(start), + Status: asString(status), + }, nil +} + +// CheckMainSchema fails fast at startup if the columns this service reads +// from Drizzle-owned tables have been renamed or dropped. +func (db *DB) CheckMainSchema(ctx context.Context) error { + checks := []string{ + "SELECT id, role, token_version, account_status FROM users WHERE 1 = 0", + "SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0", + "SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0", + "SELECT id, ticket_id, status FROM payments WHERE 1 = 0", + } + for _, q := range checks { + if _, err := db.ExecContext(ctx, q); err != nil { + return fmt.Errorf("main app schema check failed (%s): %w", q, err) + } + } + return nil +} diff --git a/photo-api/internal/store/db.go b/photo-api/internal/store/db.go new file mode 100644 index 0000000..cf22ae5 --- /dev/null +++ b/photo-api/internal/store/db.go @@ -0,0 +1,122 @@ +// Package store owns all database access. Photo tables use the photos_ +// prefix and are migrated by this service alone; the handful of reads +// against Drizzle-owned tables (users, events, tickets, payments) are +// confined to access.go. +package store + +import ( + "database/sql" + "fmt" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + _ "modernc.org/sqlite" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" +) + +const ( + Postgres = "postgres" + SQLite = "sqlite" + + // timeLayout is a fixed-width UTC format so SQLite text timestamps + // sort correctly; the backend stores ISO strings in SQLite the same way. + timeLayout = "2006-01-02T15:04:05.000Z" +) + +type DB struct { + *sql.DB + Type string +} + +func Open(cfg config.Config) (*DB, error) { + switch cfg.DBType { + case Postgres: + sqlDB, err := sql.Open("pgx", cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("open postgres: %w", err) + } + sqlDB.SetMaxOpenConns(10) + return &DB{DB: sqlDB, Type: Postgres}, nil + case SQLite: + dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") + + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + sqlDB, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + // One writer at a time keeps the shared file friendly to the backend. + sqlDB.SetMaxOpenConns(1) + return &DB{DB: sqlDB, Type: SQLite}, nil + default: + return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType) + } +} + +// Rebind converts ?-style placeholders to $n for Postgres. Queries in this +// package never contain literal question marks in strings. +func (db *DB) Rebind(query string) string { + if db.Type != Postgres { + return query + } + var b strings.Builder + n := 0 + for _, r := range query { + if r == '?' { + n++ + fmt.Fprintf(&b, "$%d", n) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +// TimeArg converts a time for binding: time.Time for Postgres (timestamptz +// columns), fixed-layout UTC text for SQLite (text columns). +func (db *DB) TimeArg(t time.Time) any { + if db.Type == Postgres { + return t.UTC() + } + return t.UTC().Format(timeLayout) +} + +// asString normalizes scanned values across drivers. +func asString(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case []byte: + return string(x) + case time.Time: + return x.UTC().Format(timeLayout) + default: + return fmt.Sprintf("%v", x) + } +} + +// asTime parses a scanned timestamp from either driver; zero time on failure. +func asTime(v any) time.Time { + switch x := v.(type) { + case time.Time: + return x.UTC() + case string: + return parseTime(x) + case []byte: + return parseTime(string(x)) + default: + return time.Time{} + } +} + +func parseTime(s string) time.Time { + for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/photo-api/internal/store/galleries.go b/photo-api/internal/store/galleries.go new file mode 100644 index 0000000..f9d5a3f --- /dev/null +++ b/photo-api/internal/store/galleries.go @@ -0,0 +1,289 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +var ErrNotFound = errors.New("not found") + +const ( + VisibilityPublic = "public" + VisibilityPrivate = "private" + VisibilityLink = "link" + VisibilityTicket = "ticket" +) + +type Gallery struct { + ID string + Slug string + Title string + TitleEs string + Description string + DescriptionEs string + EventID string + Visibility string + ShareToken string + CoverPhotoID string + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time + PhotoCount int +} + +const galleryColumns = `g.id, g.slug, g.title, g.title_es, g.description, g.description_es, + g.event_id, g.visibility, g.share_token, g.cover_photo_id, g.created_by, g.created_at, g.updated_at` + +type scanner interface{ Scan(...any) error } + +func scanGallery(s scanner, withCount bool) (Gallery, error) { + var v [13]any + dest := make([]any, 0, 14) + for i := range v { + dest = append(dest, &v[i]) + } + var count any + if withCount { + dest = append(dest, &count) + } + if err := s.Scan(dest...); err != nil { + return Gallery{}, err + } + g := Gallery{ + ID: asString(v[0]), + Slug: asString(v[1]), + Title: asString(v[2]), + TitleEs: asString(v[3]), + Description: asString(v[4]), + DescriptionEs: asString(v[5]), + EventID: asString(v[6]), + Visibility: asString(v[7]), + ShareToken: asString(v[8]), + CoverPhotoID: asString(v[9]), + CreatedBy: asString(v[10]), + CreatedAt: asTime(v[11]), + UpdatedAt: asTime(v[12]), + } + if withCount { + g.PhotoCount = int(asInt(count)) + } + return g, nil +} + +func (db *DB) CreateGallery(ctx context.Context, g Gallery) error { + _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO photos_galleries + (id, slug, title, title_es, description, description_es, event_id, visibility, share_token, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + g.ID, g.Slug, g.Title, nullable(g.TitleEs), nullable(g.Description), nullable(g.DescriptionEs), + nullable(g.EventID), g.Visibility, g.ShareToken, nullable(g.CreatedBy), + db.TimeArg(g.CreatedAt), db.TimeArg(g.UpdatedAt)) + return err +} + +func (db *DB) GetGallery(ctx context.Context, id string) (Gallery, error) { + return db.getGalleryWhere(ctx, "g.id = ?", id) +} + +func (db *DB) GetGalleryBySlug(ctx context.Context, slug string) (Gallery, error) { + return db.getGalleryWhere(ctx, "g.slug = ?", slug) +} + +// GetGalleryByEventID returns the newest gallery linked to an event, for +// the /events/{slug}/gallery public route. +func (db *DB) GetGalleryByEventID(ctx context.Context, eventID string) (Gallery, error) { + galleries, err := db.queryGalleries(ctx, ` + SELECT `+galleryColumns+`, + (SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count + FROM photos_galleries g + WHERE g.event_id = ? + ORDER BY g.created_at DESC LIMIT 1`, eventID) + if err != nil { + return Gallery{}, err + } + if len(galleries) == 0 { + return Gallery{}, ErrNotFound + } + return galleries[0], nil +} + +func (db *DB) getGalleryWhere(ctx context.Context, where string, arg any) (Gallery, error) { + row := db.QueryRowContext(ctx, db.Rebind(` + SELECT `+galleryColumns+`, + (SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count + FROM photos_galleries g WHERE `+where), arg) + g, err := scanGallery(row, true) + if errors.Is(err, sql.ErrNoRows) { + return Gallery{}, ErrNotFound + } + return g, err +} + +// ListGalleries returns every gallery (admin view). eventID filters when set. +func (db *DB) ListGalleries(ctx context.Context, eventID string, limit, offset int) ([]Gallery, error) { + q := ` + SELECT ` + galleryColumns + `, + (SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count + FROM photos_galleries g` + args := []any{} + if eventID != "" { + q += " WHERE g.event_id = ?" + args = append(args, eventID) + } + q += " ORDER BY g.created_at DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + return db.queryGalleries(ctx, q, args...) +} + +// ListPublicGalleries returns visibility='public' galleries with at least +// their ready photo counts, newest first. +func (db *DB) ListPublicGalleries(ctx context.Context) ([]Gallery, error) { + return db.queryGalleries(ctx, ` + SELECT `+galleryColumns+`, + (SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id AND p.status = 'ready') AS photo_count + FROM photos_galleries g + WHERE g.visibility = 'public' + ORDER BY g.created_at DESC`) +} + +func (db *DB) queryGalleries(ctx context.Context, q string, args ...any) ([]Gallery, error) { + rows, err := db.QueryContext(ctx, db.Rebind(q), args...) + if err != nil { + return nil, err + } + defer rows.Close() + galleries := []Gallery{} + for rows.Next() { + g, err := scanGallery(rows, true) + if err != nil { + return nil, err + } + galleries = append(galleries, g) + } + return galleries, rows.Err() +} + +type GalleryUpdate struct { + Title *string + TitleEs *string + Description *string + DescriptionEs *string + EventID *string // empty string clears the link + Visibility *string + CoverPhotoID *string +} + +func (db *DB) UpdateGallery(ctx context.Context, id string, u GalleryUpdate) error { + set := "" + args := []any{} + add := func(col string, val any) { + if set != "" { + set += ", " + } + set += col + " = ?" + args = append(args, val) + } + if u.Title != nil { + add("title", *u.Title) + } + if u.TitleEs != nil { + add("title_es", nullable(*u.TitleEs)) + } + if u.Description != nil { + add("description", nullable(*u.Description)) + } + if u.DescriptionEs != nil { + add("description_es", nullable(*u.DescriptionEs)) + } + if u.EventID != nil { + add("event_id", nullable(*u.EventID)) + } + if u.Visibility != nil { + add("visibility", *u.Visibility) + } + if u.CoverPhotoID != nil { + add("cover_photo_id", nullable(*u.CoverPhotoID)) + } + if set == "" { + return nil + } + add("updated_at", db.TimeArg(time.Now())) + args = append(args, id) + res, err := db.ExecContext(ctx, db.Rebind("UPDATE photos_galleries SET "+set+" WHERE id = ?"), args...) + if err != nil { + return err + } + return errIfNoRows(res) +} + +func (db *DB) RotateShareToken(ctx context.Context, id, token string) error { + res, err := db.ExecContext(ctx, + db.Rebind("UPDATE photos_galleries SET share_token = ?, updated_at = ? WHERE id = ?"), + token, db.TimeArg(time.Now()), id) + if err != nil { + return err + } + return errIfNoRows(res) +} + +func (db *DB) DeleteGallery(ctx context.Context, id string) error { + res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_galleries WHERE id = ?"), id) + if err != nil { + return err + } + return errIfNoRows(res) +} + +func (db *DB) SlugExists(ctx context.Context, slug string) (bool, error) { + var v any + err := db.QueryRowContext(ctx, db.Rebind("SELECT 1 FROM photos_galleries WHERE slug = ?"), slug).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +func errIfNoRows(res sql.Result) error { + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// nullable maps "" to NULL so optional text columns stay NULL not ”. +func nullable(s string) any { + if s == "" { + return nil + } + return s +} + +func asInt(v any) int64 { + switch x := v.(type) { + case int64: + return x + case int: + return int64(x) + case int32: + return int64(x) + case float64: + return int64(x) + case []byte: + var n int64 + for _, c := range x { + if c < '0' || c > '9' { + break + } + n = n*10 + int64(c-'0') + } + return n + default: + return 0 + } +} diff --git a/photo-api/internal/store/migrate.go b/photo-api/internal/store/migrate.go new file mode 100644 index 0000000..74507c1 --- /dev/null +++ b/photo-api/internal/store/migrate.go @@ -0,0 +1,115 @@ +package store + +import ( + "context" + "fmt" + "log" + "sort" + "strconv" + "strings" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/migrations" +) + +// advisoryLockKey is an arbitrary fixed key for pg_advisory_lock so two +// instances can't run migrations concurrently. SQLite relies on its file lock. +const advisoryLockKey = 792346801 + +// Migrate applies embedded migrations for the active dialect in version +// order, recording applied versions in photos_schema_migrations. +func (db *DB) Migrate(ctx context.Context) error { + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + + if db.Type == Postgres { + if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", advisoryLockKey); err != nil { + return fmt.Errorf("advisory lock: %w", err) + } + defer conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", advisoryLockKey) + } + + createVersions := "CREATE TABLE IF NOT EXISTS photos_schema_migrations (version integer PRIMARY KEY, applied_at text NOT NULL)" + if db.Type == Postgres { + createVersions = "CREATE TABLE IF NOT EXISTS photos_schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL)" + } + if _, err := conn.ExecContext(ctx, createVersions); err != nil { + return fmt.Errorf("create photos_schema_migrations: %w", err) + } + + applied := map[int]bool{} + rows, err := conn.QueryContext(ctx, "SELECT version FROM photos_schema_migrations") + if err != nil { + return err + } + for rows.Next() { + var v int + if err := rows.Scan(&v); err != nil { + rows.Close() + return err + } + applied[v] = true + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + suffix := ".sqlite.sql" + if db.Type == Postgres { + suffix = ".pg.sql" + } + entries, err := migrations.FS.ReadDir(".") + if err != nil { + return err + } + var names []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), suffix) { + names = append(names, e.Name()) + } + } + sort.Strings(names) + + for _, name := range names { + version, err := strconv.Atoi(strings.SplitN(name, "_", 2)[0]) + if err != nil { + return fmt.Errorf("migration %s: name must start with a numeric version", name) + } + if applied[version] { + continue + } + body, err := migrations.FS.ReadFile(name) + if err != nil { + return err + } + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return err + } + for _, stmt := range strings.Split(string(body), ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := tx.ExecContext(ctx, stmt); err != nil { + tx.Rollback() + return fmt.Errorf("migration %s: %w", name, err) + } + } + if _, err := tx.ExecContext(ctx, db.Rebind("INSERT INTO photos_schema_migrations (version, applied_at) VALUES (?, ?)"), + version, db.TimeArg(time.Now())); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + log.Printf("applied migration %s", name) + } + return nil +} diff --git a/photo-api/internal/store/photos.go b/photo-api/internal/store/photos.go new file mode 100644 index 0000000..cad6391 --- /dev/null +++ b/photo-api/internal/store/photos.go @@ -0,0 +1,271 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +const maxAttempts = 5 + +type Photo struct { + ID string + GalleryID string + Position int + OriginalKey string + OriginalFilename string + ContentType string + SizeBytes int64 + Width int + Height int + ThumbKey string + PreviewKey string + TakenAt time.Time + Status string + Attempts int + NextAttemptAt time.Time + LastError string + CreatedAt time.Time + UpdatedAt time.Time +} + +const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type, + size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at, + last_error, created_at, updated_at` + +func scanPhoto(s scanner) (Photo, error) { + var v [18]any + dest := make([]any, len(v)) + for i := range v { + dest[i] = &v[i] + } + if err := s.Scan(dest...); err != nil { + return Photo{}, err + } + return Photo{ + ID: asString(v[0]), + GalleryID: asString(v[1]), + Position: int(asInt(v[2])), + OriginalKey: asString(v[3]), + OriginalFilename: asString(v[4]), + ContentType: asString(v[5]), + SizeBytes: asInt(v[6]), + Width: int(asInt(v[7])), + Height: int(asInt(v[8])), + ThumbKey: asString(v[9]), + PreviewKey: asString(v[10]), + TakenAt: asTime(v[11]), + Status: asString(v[12]), + Attempts: int(asInt(v[13])), + NextAttemptAt: asTime(v[14]), + LastError: asString(v[15]), + CreatedAt: asTime(v[16]), + UpdatedAt: asTime(v[17]), + }, nil +} + +func (db *DB) InsertPhoto(ctx context.Context, p Photo) error { + _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO photos_photos + (id, gallery_id, position, original_key, original_filename, content_type, size_bytes, + status, attempts, next_attempt_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`), + p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType, + p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt)) + return err +} + +func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) { + row := db.QueryRowContext(ctx, + db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id) + p, err := scanPhoto(row) + if errors.Is(err, sql.ErrNoRows) { + return Photo{}, ErrNotFound + } + return p, err +} + +// ListPhotos returns a gallery's photos in position order. When readyOnly is +// set (public callers), photos still processing or failed are omitted. +func (db *DB) ListPhotos(ctx context.Context, galleryID string, readyOnly bool) ([]Photo, error) { + q := "SELECT " + photoColumns + " FROM photos_photos WHERE gallery_id = ?" + if readyOnly { + q += " AND status = 'ready'" + } + q += " ORDER BY position, created_at" + rows, err := db.QueryContext(ctx, db.Rebind(q), galleryID) + if err != nil { + return nil, err + } + defer rows.Close() + photos := []Photo{} + for rows.Next() { + p, err := scanPhoto(rows) + if err != nil { + return nil, err + } + photos = append(photos, p) + } + return photos, rows.Err() +} + +func (db *DB) NextPosition(ctx context.Context, galleryID string) (int, error) { + var v any + err := db.QueryRowContext(ctx, + db.Rebind("SELECT COALESCE(MAX(position), -1) + 1 FROM photos_photos WHERE gallery_id = ?"), + galleryID).Scan(&v) + return int(asInt(v)), err +} + +// ReorderPhotos rewrites positions to match ids order, in one transaction. +// ids must be exactly the gallery's photo ids (validated by the handler). +func (db *DB) ReorderPhotos(ctx context.Context, galleryID string, ids []string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + q := db.Rebind("UPDATE photos_photos SET position = ?, updated_at = ? WHERE id = ? AND gallery_id = ?") + now := db.TimeArg(time.Now()) + for i, id := range ids { + if _, err := tx.ExecContext(ctx, q, i, now, id, galleryID); err != nil { + return err + } + } + return tx.Commit() +} + +func (db *DB) DeletePhoto(ctx context.Context, id string) error { + res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_photos WHERE id = ?"), id) + if err != nil { + return err + } + return errIfNoRows(res) +} + +// PhotoKeys returns every storage key of a gallery, for object cleanup +// before the rows cascade away. +func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error) { + rows, err := db.QueryContext(ctx, db.Rebind( + "SELECT original_key, thumb_key, preview_key FROM photos_photos WHERE gallery_id = ?"), galleryID) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []string + for rows.Next() { + var a, b, c any + if err := rows.Scan(&a, &b, &c); err != nil { + return nil, err + } + for _, k := range []string{asString(a), asString(b), asString(c)} { + if k != "" { + keys = append(keys, k) + } + } + } + return keys, rows.Err() +} + +// ClaimNextPhoto picks the oldest due queued/failed photo and marks it +// processing. Optimistic claim (RowsAffected check) works identically on +// Postgres and SQLite; returns ErrNotFound when the queue is empty. +func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) { + now := time.Now() + var idRaw any + err := db.QueryRowContext(ctx, db.Rebind(` + SELECT id FROM photos_photos + WHERE status IN ('queued','failed') AND attempts < ? AND next_attempt_at <= ? + ORDER BY created_at LIMIT 1`), + maxAttempts, db.TimeArg(now)).Scan(&idRaw) + if errors.Is(err, sql.ErrNoRows) { + return Photo{}, ErrNotFound + } + if err != nil { + return Photo{}, err + } + id := asString(idRaw) + res, err := db.ExecContext(ctx, db.Rebind(` + UPDATE photos_photos SET status = 'processing', attempts = attempts + 1, updated_at = ? + WHERE id = ? AND status IN ('queued','failed')`), + db.TimeArg(now), id) + if err != nil { + return Photo{}, err + } + if n, _ := res.RowsAffected(); n == 0 { + return Photo{}, ErrNotFound // lost the race; caller loops again + } + return db.GetPhoto(ctx, id) +} + +func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error { + var takenArg any + if !takenAt.IsZero() { + takenArg = db.TimeArg(takenAt) + } + _, err := db.ExecContext(ctx, db.Rebind(` + UPDATE photos_photos + SET status = 'ready', thumb_key = ?, preview_key = ?, width = ?, height = ?, taken_at = ?, + last_error = NULL, updated_at = ? + WHERE id = ?`), + thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id) + return err +} + +func (db *DB) MarkPhotoFailed(ctx context.Context, id string, attempts int, cause error) error { + backoff := time.Duration(1< 1000 { + msg = msg[:1000] + } + _, err := db.ExecContext(ctx, db.Rebind(` + UPDATE photos_photos SET status = 'failed', last_error = ?, next_attempt_at = ?, updated_at = ? + WHERE id = ?`), + msg, db.TimeArg(time.Now().Add(backoff)), db.TimeArg(time.Now()), id) + return err +} + +// RequeuePhoto resets a failed photo for a fresh round of attempts. +func (db *DB) RequeuePhoto(ctx context.Context, id string) error { + res, err := db.ExecContext(ctx, db.Rebind(` + UPDATE photos_photos SET status = 'queued', attempts = 0, next_attempt_at = ?, updated_at = ? + WHERE id = ? AND status IN ('failed','queued')`), + db.TimeArg(time.Now()), db.TimeArg(time.Now()), id) + if err != nil { + return err + } + return errIfNoRows(res) +} + +// RecoverStuckProcessing requeues photos left in 'processing' by a crash. +func (db *DB) RecoverStuckProcessing(ctx context.Context, olderThan time.Duration) (int64, error) { + res, err := db.ExecContext(ctx, db.Rebind(` + UPDATE photos_photos SET status = 'queued', updated_at = ? + WHERE status = 'processing' AND updated_at < ?`), + db.TimeArg(time.Now()), db.TimeArg(time.Now().Add(-olderThan))) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// GalleryCoverKeys returns thumb keys for cover selection: the explicit +// cover photo's thumb when set and ready, else the first ready photo's. +func (db *DB) GalleryCoverKey(ctx context.Context, g Gallery) (photoID, thumbKey string) { + if g.CoverPhotoID != "" { + if p, err := db.GetPhoto(ctx, g.CoverPhotoID); err == nil && p.Status == "ready" && p.GalleryID == g.ID { + return p.ID, p.ThumbKey + } + } + row := db.QueryRowContext(ctx, db.Rebind(` + SELECT id, thumb_key FROM photos_photos + WHERE gallery_id = ? AND status = 'ready' + ORDER BY position, created_at LIMIT 1`), g.ID) + var idRaw, keyRaw any + if err := row.Scan(&idRaw, &keyRaw); err != nil { + return "", "" + } + return asString(idRaw), asString(keyRaw) +} diff --git a/photo-api/internal/worker/worker.go b/photo-api/internal/worker/worker.go new file mode 100644 index 0000000..be7e455 --- /dev/null +++ b/photo-api/internal/worker/worker.go @@ -0,0 +1,185 @@ +// Package worker turns queued photos_photos rows into ready ones by +// generating the thumb/preview variants. The rows themselves are the queue +// (status/attempts/next_attempt_at) — no Redis, no jobs table. Claims are +// optimistic UPDATEs so they are safe on both Postgres and SQLite. +package worker + +import ( + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +const ( + pollInterval = 10 * time.Second + stuckAfter = 10 * time.Minute +) + +type Worker struct { + db *store.DB + storage storage.Storage + heic *imaging.HeicConverter + tmpDir string + nudge chan struct{} + parallel int +} + +func New(db *store.DB, st storage.Storage, heic *imaging.HeicConverter, tmpDir string, parallel int) *Worker { + if parallel < 1 { + parallel = 1 + } + return &Worker{ + db: db, + storage: st, + heic: heic, + tmpDir: tmpDir, + nudge: make(chan struct{}, 1), + parallel: parallel, + } +} + +// Nudge wakes the worker after an upload so the poll interval is only a +// fallback. +func (w *Worker) Nudge() { + select { + case w.nudge <- struct{}{}: + default: + } +} + +func (w *Worker) Run(ctx context.Context) { + if n, err := w.db.RecoverStuckProcessing(ctx, stuckAfter); err != nil { + log.Printf("worker: recover stuck: %v", err) + } else if n > 0 { + log.Printf("worker: requeued %d photos stuck in processing", n) + } + for i := 0; i < w.parallel; i++ { + go w.loop(ctx) + } +} + +func (w *Worker) loop(ctx context.Context) { + for { + worked := w.drain(ctx) + if ctx.Err() != nil { + return + } + if worked { + continue + } + select { + case <-ctx.Done(): + return + case <-w.nudge: + case <-time.After(pollInterval): + } + } +} + +// drain processes until the queue is empty; returns whether anything ran. +func (w *Worker) drain(ctx context.Context) bool { + worked := false + for ctx.Err() == nil { + photo, err := w.db.ClaimNextPhoto(ctx) + if err == store.ErrNotFound { + return worked + } + if err != nil { + log.Printf("worker: claim: %v", err) + return worked + } + worked = true + if err := w.process(ctx, photo); err != nil { + log.Printf("worker: photo %s attempt %d failed: %v", photo.ID, photo.Attempts, err) + if dberr := w.db.MarkPhotoFailed(ctx, photo.ID, photo.Attempts, err); dberr != nil { + log.Printf("worker: mark failed: %v", dberr) + } + } + } + return worked +} + +func (w *Worker) process(ctx context.Context, p store.Photo) error { + work, err := os.MkdirTemp(w.tmpDir, "photo-*") + if err != nil { + return err + } + defer os.RemoveAll(work) + + // 1. Fetch the original to a local file. + src := filepath.Join(work, "original") + if err := w.download(ctx, p.OriginalKey, src); err != nil { + return fmt.Errorf("fetch original: %w", err) + } + + // 2. HEIC → JPEG via the external converter before the pure-Go pipeline. + if p.ContentType == "image/heic" { + if w.heic == nil { + return fmt.Errorf("HEIC converter not installed on this host (install libvips-tools or libheif-examples)") + } + converted := filepath.Join(work, "converted.jpg") + if err := w.heic.ToJPEG(src, converted); err != nil { + return err + } + src = converted + } + + // 3. Decode, orient, resize, encode. + thumbPath := filepath.Join(work, "thumb.jpg") + previewPath := filepath.Join(work, "preview.jpg") + res, err := imaging.ProcessFile(src, thumbPath, previewPath) + if err != nil { + return err + } + + // 4. Store variants next to the original's key. + base := strings.TrimSuffix(filepath.Base(p.OriginalKey), filepath.Ext(p.OriginalKey)) + prefix := fmt.Sprintf("galleries/%s", p.GalleryID) + thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base) + previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base) + if err := w.upload(ctx, thumbKey, thumbPath); err != nil { + return fmt.Errorf("store thumb: %w", err) + } + if err := w.upload(ctx, previewKey, previewPath); err != nil { + return fmt.Errorf("store preview: %w", err) + } + + return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt) +} + +func (w *Worker) download(ctx context.Context, key, dst string) error { + r, _, err := w.storage.Open(ctx, key) + if err != nil { + return err + } + defer r.Close() + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, r) + return err +} + +func (w *Worker) upload(ctx context.Context, key, src string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return err + } + return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg") +} diff --git a/photo-api/migrations/0001_init.pg.sql b/photo-api/migrations/0001_init.pg.sql new file mode 100644 index 0000000..4b0fc97 --- /dev/null +++ b/photo-api/migrations/0001_init.pg.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS photos_galleries ( + id uuid PRIMARY KEY, + slug varchar(160) NOT NULL UNIQUE, + title varchar(200) NOT NULL, + title_es varchar(200), + description text, + description_es text, + event_id uuid, + visibility varchar(10) NOT NULL DEFAULT 'private' + CHECK (visibility IN ('public','private','link','ticket')), + share_token varchar(64) NOT NULL UNIQUE, + cover_photo_id uuid, + created_by uuid, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_galleries_event_idx ON photos_galleries (event_id); + +CREATE INDEX IF NOT EXISTS photos_galleries_visibility_idx ON photos_galleries (visibility); + +CREATE TABLE IF NOT EXISTS photos_photos ( + id uuid PRIMARY KEY, + gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + original_key text NOT NULL, + original_filename text, + content_type varchar(50) NOT NULL, + size_bytes bigint NOT NULL, + width integer, + height integer, + thumb_key text, + preview_key text, + taken_at timestamptz, + status varchar(12) NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','processing','ready','failed')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL, + last_error text, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position); + +CREATE INDEX IF NOT EXISTS photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed'); diff --git a/photo-api/migrations/0001_init.sqlite.sql b/photo-api/migrations/0001_init.sqlite.sql new file mode 100644 index 0000000..07a2bb7 --- /dev/null +++ b/photo-api/migrations/0001_init.sqlite.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS photos_galleries ( + id text PRIMARY KEY, + slug text NOT NULL UNIQUE, + title text NOT NULL, + title_es text, + description text, + description_es text, + event_id text, + visibility text NOT NULL DEFAULT 'private' + CHECK (visibility IN ('public','private','link','ticket')), + share_token text NOT NULL UNIQUE, + cover_photo_id text, + created_by text, + created_at text NOT NULL, + updated_at text NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_galleries_event_idx ON photos_galleries (event_id); + +CREATE INDEX IF NOT EXISTS photos_galleries_visibility_idx ON photos_galleries (visibility); + +CREATE TABLE IF NOT EXISTS photos_photos ( + id text PRIMARY KEY, + gallery_id text NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + original_key text NOT NULL, + original_filename text, + content_type text NOT NULL, + size_bytes integer NOT NULL, + width integer, + height integer, + thumb_key text, + preview_key text, + taken_at text, + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','processing','ready','failed')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at text NOT NULL, + last_error text, + created_at text NOT NULL, + updated_at text NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position); + +CREATE INDEX IF NOT EXISTS photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed'); diff --git a/photo-api/migrations/embed.go b/photo-api/migrations/embed.go new file mode 100644 index 0000000..a610564 --- /dev/null +++ b/photo-api/migrations/embed.go @@ -0,0 +1,8 @@ +// Package migrations embeds the dual-dialect SQL migrations owned by +// photo-api. Drizzle (backend) never sees these tables. +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b2b2848 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,763 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + next: + specifier: ^16.2.10 + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + devDependencies: + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} + + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@next/env@16.2.10': {} + + '@next/swc-darwin-arm64@16.2.10': + optional: true + + '@next/swc-darwin-x64@16.2.10': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.10': + optional: true + + '@next/swc-linux-arm64-musl@16.2.10': + optional: true + + '@next/swc-linux-x64-gnu@16.2.10': + optional: true + + '@next/swc-linux-x64-musl@16.2.10': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.10': + optional: true + + '@next/swc-win32-x64-msvc@16.2.10': + optional: true + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + baseline-browser-mapping@2.10.43: {} + + caniuse-lite@1.0.30001805: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.18.1 + rxjs: 7.8.2 + shell-quote: 1.10.0 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.3 + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.7 + + detect-libc@2.1.2: + optional: true + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + lodash@4.18.1: {} + + nanoid@3.3.16: {} + + next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@next/env': 16.2.10 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + postcss: 8.4.31 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + picocolors@1.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + require-directory@2.1.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + scheduler@0.27.0: {} + + semver@7.8.5: + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shell-quote@1.10.0: {} + + source-map-js@1.2.1: {} + + spawn-command@0.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + styled-jsx@5.1.6(react@19.2.7): + dependencies: + client-only: 0.0.1 + react: 19.2.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1