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 <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-25 17:32:23 +00:00
co-authored by Cursor
parent 4772b85f3d
commit c9a600b6d6
61 changed files with 6912 additions and 7 deletions
+5
View File
@@ -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
+23
View File
@@ -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;
+18
View File
@@ -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;
+30
View File
@@ -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
+4
View File
@@ -5,3 +5,7 @@ upstream spanglish_frontend {
upstream spanglish_backend {
server 127.0.0.1:3018;
}
upstream spanglish_photos {
server 127.0.0.1:3020;
}
+5
View File
@@ -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)
+11
View File
@@ -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*`,
@@ -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<Metadata> {
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.
<Suspense>
<GalleryClient eventSlug={params.id} initial={initial} />
</Suspense>
);
}
@@ -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 (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title text-center mb-2">
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
</h1>
<p className="text-center text-gray-600 mb-10">
{es
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
: 'Memories from our language exchanges in Asunción'}
</p>
{galleries.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600">
{es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{galleries.map((g) => (
// Event-linked galleries live under the event's URL.
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
<div className="aspect-video bg-gray-100 flex items-center justify-center">
{g.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
) : (
<CameraIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<div className="p-4">
<h2 className="font-heading font-semibold text-lg text-primary-dark">
{es && g.titleEs ? g.titleEs : g.title}
</h2>
<p className="text-sm text-gray-600 mt-1">
{g.photoCount} {es ? 'fotos' : 'photos'}
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
</p>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}
@@ -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<PhotoGallery | null>(initial?.gallery ?? null);
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
const [loading, setLoading] = useState(!initial);
const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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 (
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
}
if (denied === 'login') {
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
return (
<GateMessage
icon={LockClosedIcon}
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
{es ? 'Iniciar sesión' : 'Log in'}
</Button>
}
/>
);
}
if (denied === 'ticket') {
return (
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
}
action={
eventSlug || gallery?.event ? (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
) : undefined
}
/>
);
}
if (denied === 'notfound' || !gallery) {
return (
<GateMessage
icon={CameraIcon}
title={es ? 'Galería no encontrada' : 'Gallery not found'}
body={
es
? 'Este enlace no existe o ya no está disponible.'
: 'This link does not exist or is no longer available.'
}
action={
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
}
/>
);
}
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 (
<div>
{/* Hero */}
<div className="relative bg-brand-navy overflow-hidden">
{heroUrl && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
)}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
</div>
</div>
</div>
{/* Masonry grid */}
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
{readyPhotos.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600">
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
</p>
</div>
) : (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{readyPhotos.map((photo, i) => (
<div
key={photo.id}
role="button"
tabIndex={0}
onClick={() => 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 */}
<img
src={photo.urls.thumb}
alt=""
loading={i < 8 ? 'eager' : 'lazy'}
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<a
href={photo.urls.original}
download={photo.originalFilename || true}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
aria-label={es ? 'Descargar' : 'Download'}
>
<ArrowDownTrayIcon className="w-4 h-4" />
</a>
</div>
))}
</div>
)}
{lightboxIndex !== null && (
<Lightbox
items={lightboxItems}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
/>
)}
</div>
</div>
);
}
function GateMessage({
icon: Icon,
title,
body,
action,
}: {
icon: typeof CameraIcon;
title: string;
body: string;
action?: React.ReactNode;
}) {
return (
<div className="section-padding">
<div className="container-page max-w-lg mx-auto text-center py-16">
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
<p className="text-gray-600 mb-6">{body}</p>
{action}
</div>
</div>
);
}
@@ -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<Metadata> {
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.
<Suspense>
<GalleryClient slug={params.slug} initial={initial} />
</Suspense>
);
}
+30
View File
@@ -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<PhotoGallery[]> {
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 <PhotosIndexClient galleries={galleries} />;
}
+2
View File
@@ -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'] },
@@ -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 (
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${meta.className}`}>
<Icon className="w-3.5 h-3.5" />
{locale === 'es' ? meta.es : meta.en}
</span>
);
}
+617
View File
@@ -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<PhotoGallery | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [dragId, setDragId] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Uploader panel state (Google Drive style: one row per file).
const [uploads, setUploads] = useState<UploadItem[]>([]);
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<UploadItem>) => {
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<typeof photosApi.updateGallery>[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<boolean> => {
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 (
<div>
<Skeleton className="h-8 w-64 mb-6" />
<ImageGridSkeleton />
</div>
);
}
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 (
<div
onDragOver={(e) => {
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
}}
onDrop={(e) => {
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
handleUpload(e.dataTransfer.files);
}
}}
>
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3 min-w-0">
<Link href="/admin/photos" className="text-gray-400 hover:text-gray-600">
<ArrowLeftIcon className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-bold text-primary-dark truncate">
{es && gallery.titleEs ? gallery.titleEs : gallery.title}
</h1>
<VisibilityBadge visibility={gallery.visibility} />
</div>
<div className="flex items-center gap-2">
<Button onClick={() => fileInputRef.current?.click()}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
{es ? 'Subir fotos' : 'Upload photos'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/heic,.heic"
multiple
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
/>
</div>
</div>
{/* Settings */}
<Card className="p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Visibilidad' : 'Visibility'}</label>
<select
value={gallery.visibility}
onChange={(e) =>
saveField(
{ visibility: e.target.value as GalleryVisibility },
es ? 'Visibilidad actualizada' : 'Visibility updated'
)
}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="private">{es ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
<option value="public">{es ? 'Pública (listada)' : 'Public (listed)'}</option>
<option value="link">{es ? 'Solo con enlace' : 'Link only'}</option>
<option value="ticket">{es ? 'Con entrada del evento' : 'Ticket holders of the event'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Evento vinculado' : 'Linked event'}</label>
<select
value={gallery.eventId || ''}
onChange={(e) =>
saveField({ eventId: e.target.value }, es ? 'Evento actualizado' : 'Event updated')
}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">{es ? 'Ninguno' : 'None'}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
{gallery.visibility === 'ticket' && !gallery.eventId && (
<p className="text-xs text-red-600 mt-1">
{es
? 'El modo "con entrada" necesita un evento vinculado'
: 'Ticket mode needs a linked event'}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Enlace para compartir' : 'Share link'}</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={gallery.shareUrl || ''}
className="flex-1 min-w-0 px-3 py-2 text-sm border rounded-btn bg-gray-50"
/>
<Button size="sm" onClick={copyShareLink} title={es ? 'Copiar' : 'Copy'}>
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
</Button>
<Button size="sm" variant="outline" onClick={rotateToken} title={es ? 'Regenerar' : 'Regenerate'}>
<ArrowPathIcon className="w-4 h-4" />
</Button>
</div>
{gallery.event && (
<p className="text-xs text-gray-500 mt-1">
{es
? 'Publicada en la página del evento: '
: 'Published on the event page: '}
<span className="font-mono">/events/{gallery.event.slug}/gallery</span>
</p>
)}
</div>
</div>
</Card>
{processingCount > 0 && (
<p className="text-sm text-gray-600 mb-4">
{es
? `Procesando ${processingCount} foto(s)…`
: `Processing ${processingCount} photo(s)…`}
</p>
)}
{/* Photo grid */}
{photos.length === 0 ? (
<Card className="p-12 text-center">
<ArrowUpTrayIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">
{es
? 'Arrastra fotos aquí o usa el botón de subir'
: 'Drag photos here or use the upload button'}
</p>
</Card>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{photos.map((photo) => (
<Card
key={photo.id}
draggable
onDragStart={() => 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
<img
src={photo.urls.thumb}
alt=""
className="w-full h-full object-cover cursor-pointer"
draggable={false}
onClick={() => openLightboxFor(photo.id)}
/>
) : photo.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 text-red-600 p-2 text-center">
<ExclamationTriangleIcon className="w-8 h-8 mb-1" />
<span className="text-xs line-clamp-3">{photo.lastError || 'Failed'}</span>
</div>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
)}
{gallery.coverPhotoId === photo.id && (
<StarIconSolid className="absolute top-2 left-2 w-5 h-5 text-primary-yellow drop-shadow" />
)}
<div className="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-1.5">
{photo.status === 'ready' && (
<button
onClick={() =>
saveField({ coverPhotoId: photo.id }, es ? 'Portada actualizada' : 'Cover updated')
}
className="p-1.5 text-white hover:text-primary-yellow"
title={es ? 'Usar como portada' : 'Set as cover'}
>
<StarIcon className="w-5 h-5" />
</button>
)}
{photo.status === 'failed' && (
<button
onClick={() => retryPhoto(photo.id)}
className="p-1.5 text-white hover:text-primary-yellow"
title={es ? 'Reintentar' : 'Retry'}
>
<ArrowPathIcon className="w-5 h-5" />
</button>
)}
<button
onClick={() => deletePhoto(photo.id)}
className="p-1.5 text-white hover:text-red-400"
title={es ? 'Eliminar' : 'Delete'}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</Card>
))}
</div>
)}
{/* Danger zone */}
<div className="mt-10 pt-6 border-t border-secondary-light-gray flex justify-end">
<Button variant="danger" onClick={deleteGallery}>
<TrashIcon className="w-5 h-5 mr-2" />
{es ? 'Eliminar galería' : 'Delete gallery'}
</Button>
</div>
{/* Lightbox with admin actions */}
{lightboxIndex !== null && lightboxItems.length > 0 && (
<Lightbox
items={lightboxItems}
index={Math.min(lightboxIndex, lightboxItems.length - 1)}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
renderActions={(item) => (
<>
<button
onClick={() =>
saveField({ coverPhotoId: item.id }, es ? 'Portada actualizada' : 'Cover updated')
}
className="text-white hover:text-primary-yellow"
title={es ? 'Usar como portada' : 'Set as cover'}
>
{gallery.coverPhotoId === item.id ? (
<StarIconSolid className="w-7 h-7 text-primary-yellow" />
) : (
<StarIcon className="w-7 h-7" />
)}
</button>
<button
onClick={async () => {
const before = readyPhotos.length;
const deleted = await deletePhoto(item.id);
if (!deleted) return;
if (before <= 1) setLightboxIndex(null);
else setLightboxIndex((i) => (i === null ? null : Math.max(0, Math.min(i, before - 2))));
}}
className="text-white hover:text-red-400"
title={es ? 'Eliminar' : 'Delete'}
>
<TrashIcon className="w-7 h-7" />
</button>
</>
)}
/>
)}
{/* Uploader panel (Google Drive style) */}
{uploads.length > 0 && (
<div className="fixed bottom-4 right-4 z-40 w-96 max-w-[calc(100vw-2rem)]">
<div className="bg-white rounded-card shadow-card-hover border border-secondary-light-gray overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-primary-dark text-white">
<p className="text-sm font-medium">
{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`}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => setPanelCollapsed((c) => !c)}
className="p-1 hover:text-primary-yellow"
aria-label={panelCollapsed ? 'Expand' : 'Collapse'}
>
{panelCollapsed ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />}
</button>
{activeUploads === 0 && (
<button
onClick={() => setUploads([])}
className="p-1 hover:text-primary-yellow"
aria-label="Close"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
{!panelCollapsed && (
<ul className="max-h-72 overflow-y-auto divide-y divide-secondary-light-gray">
{uploads.map((u) => {
const ds = displayStatus(u);
return (
<li key={u.key} className="px-4 py-2.5">
<div className="flex items-center gap-3">
<PhotoIcon className="w-5 h-5 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-primary-dark truncate" title={u.name}>
{u.name}
</p>
<p className="text-xs text-gray-500">
{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' && (
<span className="text-red-600" title={ds.error}>
{ds.error}
</span>
)}
</p>
</div>
<span className="shrink-0">
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
{ds.state === 'processing' && (
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
)}
{ds.state === 'uploading' && (
<span className="text-xs text-gray-600 tabular-nums">{Math.round(u.progress * 100)}%</span>
)}
</span>
</div>
{ds.state === 'uploading' && (
<div className="mt-1.5 h-1 rounded-full bg-secondary-gray overflow-hidden">
<div
className="h-full bg-primary-yellow transition-[width] duration-200"
style={{ width: `${Math.round(u.progress * 100)}%` }}
/>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
);
}
+221
View File
@@ -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<PhotoGallery[]>([]);
const [events, setEvents] = useState<Event[]>([]);
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 (
<div>
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<ImageGridSkeleton />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">
{locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'}
</h1>
<Button onClick={() => setShowCreate(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
</Button>
</div>
{galleries.length === 0 ? (
<Card className="p-12 text-center">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2">
{locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'}
</h3>
<p className="text-gray-500 mb-4">
{locale === 'es'
? 'Crea una galería para compartir las fotos de un evento'
: 'Create a gallery to share photos from a past event'}
</p>
<Button onClick={() => setShowCreate(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Crear la primera' : 'Create the first one'}
</Button>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{galleries.map((g) => (
<Link key={g.id} href={`/admin/photos/${g.id}`}>
<Card className="overflow-hidden hover:shadow-card-hover transition-shadow cursor-pointer h-full">
<div className="aspect-video bg-gray-100 flex items-center justify-center">
{g.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" />
) : (
<CameraIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-primary-dark truncate">
{locale === 'es' && g.titleEs ? g.titleEs : g.title}
</h3>
<VisibilityBadge visibility={g.visibility} />
</div>
<p className="text-sm text-gray-500 mt-1">
{g.photoCount} {locale === 'es' ? 'fotos' : 'photos'}
{g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}</>}
</p>
</div>
</Card>
</Link>
))}
</div>
)}
{showCreate && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-primary-dark">
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
</h2>
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-gray-600">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Título (inglés)' : 'Title (English)'} *
</label>
<input
type="text"
required
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
placeholder="June Language Exchange"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Título (español)' : 'Title (Spanish)'}
</label>
<input
type="text"
value={form.titleEs}
onChange={(e) => setForm({ ...form, titleEs: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
placeholder="Intercambio de Junio"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Evento vinculado' : 'Linked event'}
</label>
<select
value={form.eventId}
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">{locale === 'es' ? 'Ninguno' : 'None'}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Visibilidad' : 'Visibility'}
</label>
<select
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as GalleryVisibility })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="private">{locale === 'es' ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
<option value="public">{locale === 'es' ? 'Pública (listada)' : 'Public (listed)'}</option>
<option value="link">{locale === 'es' ? 'Solo con enlace' : 'Link only'}</option>
<option value="ticket">
{locale === 'es' ? 'Con entrada del evento' : 'Ticket holders of the event'}
</option>
</select>
</div>
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
<Button type="submit" isLoading={creating}>
{locale === 'es' ? 'Crear' : 'Create'}
</Button>
</div>
</form>
</Card>
</div>
)}
</div>
);
}
+39 -2
View File
@@ -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<SitemapPhotoGallery[]> {
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<SitemapEvent[]> {
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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<MetadataRoute.Sitemap> {
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<MetadataRoute.Sitemap> {
};
});
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];
}
+145
View File
@@ -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<number | null>(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 (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
onClick={onClose}
onTouchStart={(e) => 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);
}}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10 p-2 -m-2"
onClick={onClose}
aria-label="Close"
>
<XMarkIcon className="w-8 h-8" />
</button>
<div
className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
{renderActions?.(item, index)}
</div>
{items.length > 1 && (
<>
<button
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
{index + 1} / {items.length}
</div>
{/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden>
{items.length > 1 && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index - 1 + items.length) % items.length].previewUrl} alt="" />
</>
)}
</div>
</div>
);
}
+1
View File
@@ -275,6 +275,7 @@
"contacts": "Messages",
"emails": "Emails",
"gallery": "Gallery",
"photos": "Photos",
"settings": "Settings"
},
"events": {
+1
View File
@@ -275,6 +275,7 @@
"contacts": "Mensajes",
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
"settings": "Configuración"
},
"events": {
+8
View File
@@ -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';
+176
View File
@@ -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<CreateGalleryInput> & { 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}`
);
},
};
+18 -4
View File
@@ -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"
]
}
+5 -1
View File
@@ -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",
+43
View File
@@ -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=
+25
View File
@@ -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"]
+23
View File
@@ -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
+334
View File
@@ -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 ~716738). `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 236246). Payload: `{ sub: userId, email, role, tokenVersion, iat, exp }`. Transport is `Authorization: Bearer <token>`; 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/<domain>`; 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 <existing JWT>`. "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/<slug>?token=<share_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="<original_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/<id>/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/<galleryID>/orig/<photoID>.<ext>`, `.../thumb/<photoID>.jpg`, `.../preview/<photoID>.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 ~13 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.
+92
View File
@@ -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=<url> 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.
+106
View File
@@ -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)
}
}
+46
View File
@@ -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
)
+117
View File
@@ -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=
+99
View File
@@ -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
}
+126
View File
@@ -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)
}
}
}
+55
View File
@@ -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"}
}
}
+504
View File
@@ -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)
}
}
+153
View File
@@ -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/<slug> 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
}
+117
View File
@@ -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 ""
}
+225
View File
@@ -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
}
+36
View File
@@ -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
}
+242
View File
@@ -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
}
+79
View File
@@ -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,
})
}
+39
View File
@@ -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)
}
+90
View File
@@ -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)
}
+72
View File
@@ -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)
}
+60
View File
@@ -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> <src> <dst>
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
}
+159
View File
@@ -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
}
+30
View File
@@ -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"
}
+91
View File
@@ -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
}
+92
View File
@@ -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
}
+32
View File
@@ -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/<galleryID>/<variantDir>/<photoID>.<ext>.
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)
}
+113
View File
@@ -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
}
+122
View File
@@ -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{}
}
+289
View File
@@ -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
}
}
+115
View File
@@ -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
}
+271
View File
@@ -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<<min(attempts, 6)) * time.Minute
msg := fmt.Sprintf("%v", cause)
if len(msg) > 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)
}
+185
View File
@@ -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")
}
+46
View File
@@ -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');
+46
View File
@@ -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');
+8
View File
@@ -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
+763
View File
@@ -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