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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -275,6 +275,7 @@
|
||||
"contacts": "Messages",
|
||||
"emails": "Emails",
|
||||
"gallery": "Gallery",
|
||||
"photos": "Photos",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"events": {
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
"contacts": "Mensajes",
|
||||
"emails": "Emails",
|
||||
"gallery": "Galería",
|
||||
"photos": "Fotos",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"events": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,334 @@
|
||||
# Photo API — Implementation Plan
|
||||
|
||||
Event photo galleries for the Spanglish platform, built as a standalone Go service in `photo-api/`.
|
||||
Written 2026-07-24 after a read-only investigation of the repo.
|
||||
|
||||
> **Revised 2026-07-24 after review.** Decisions from the review that amend the sections below:
|
||||
> 1. **Both Postgres and SQLite are supported from day 1**, mirroring the backend's `DB_TYPE`/`DATABASE_URL` convention. Consequence: the dedicated `photos` Postgres schema is dropped (SQLite has no schemas); instead every table carries a **`photos_` name prefix** in both engines, which still cannot collide with any Drizzle-owned table name. Migrations are dual-dialect (`*.pg.sql` / `*.sqlite.sql`), SQLite driver is `modernc.org/sqlite` (pure Go, keeps the cgo-free static binary), and queue claiming uses `FOR UPDATE SKIP LOCKED` on Postgres and a plain claim-UPDATE on SQLite.
|
||||
> 2. Deployment stays **systemd-first with the Dockerfile as the secondary path** (confirmed).
|
||||
> 3. **Free events**: any `confirmed`/`checked_in` ticket grants access when the event's `price = 0`; paid events still require a `payments.status='paid'` row (§7 SQL updated).
|
||||
> 4. Admin nav keeps the name **"Photos"** alongside the existing "Gallery" item (confirmed).
|
||||
> 5. **HEIC is supported**, not rejected: uploads sniffed as HEIC are converted to JPEG for the variant pipeline by shelling out to a host-installed converter (`vips` from libvips-tools, falling back to `heif-convert`); the Go binary stays cgo-free and the original `.heic` is stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged.
|
||||
> 6. Photo admin surface is **`admin`/`organizer` only** (confirmed).
|
||||
> 7. **Local disk and S3 both supported from day 1** (was already planned).
|
||||
> 8. The service has **its own `photo-api/.env`** (was already planned).
|
||||
|
||||
---
|
||||
|
||||
## 0. Investigation summary (what the codebase actually is)
|
||||
|
||||
**Database.** Drizzle schema lives in one file, `backend/src/db/schema.ts`, with *dual* SQLite/Postgres table definitions selected at runtime by `DB_TYPE` (bottom-of-file conditional exports, lines ~716–738). `drizzle.config.ts` + `npm run db:generate` exist, but the migration that actually runs is the hand-written idempotent script `backend/src/db/migrate.ts` (`npm run db:migrate`), which is **not** run automatically at server start. The local `backend/.env` sets `DB_TYPE=postgres` with `DATABASE_URL=postgresql://spanglish:...@localhost:5432/spanglish_db` (driver: `pg` via `drizzle-orm/node-postgres`, everything in the `public` schema). Relevant tables:
|
||||
|
||||
- `events`: `id` (uuid in PG), `slug`, bilingual title/description fields, `start_datetime`, `status` (`draft|published|unlisted|cancelled|completed|archived`), `banner_url`, …
|
||||
- `tickets`: `id`, `booking_id`, `user_id`, `event_id`, attendee fields, `status` (`pending|confirmed|cancelled|checked_in|on_hold`), `is_guest`, …
|
||||
- `payments`: one row per ticket (`ticket_id` FK), `status` (`pending|pending_approval|paid|refunded|failed|cancelled|on_hold`), `provider` (`bancard|lightning|cash|bank_transfer|tpago`), `paid_at`.
|
||||
|
||||
**A ticket is "paid" when its `payments` row has `status='paid'`** (set in `backend/src/routes/payments.ts` on admin approval/PATCH, and in `backend/src/routes/lnbits.ts` for Lightning), at which point the ticket itself is set to `confirmed`. "Paid" lives on the payment, not the ticket.
|
||||
|
||||
**Auth.** Stateless JWT, library `jose`, **HS256** with the shared symmetric secret `JWT_SECRET`, issuer `spanglish`, audience `spanglish-app`, 1-day expiry (`backend/src/lib/auth.ts`, `createToken` lines 236–246). Payload: `{ sub: userId, email, role, tokenVersion, iat, exp }`. Transport is `Authorization: Bearer <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 ~1–3 s/photo per variant on large JPEGs — fine for an async queue.
|
||||
|
||||
- **HEIC (required per review): decoded by shelling out**, since no viable pure-Go HEVC decoder exists. The worker converts HEIC→JPEG (quality 95, temp file in the service's data dir) via the first available of: `HEIC_CONVERTER` (explicit path/command), `vips copy` (Debian package `libvips-tools`), `heif-convert` (package `libheif-examples`), then feeds the JPEG into the normal pure-Go pipeline. The Go binary itself stays cgo-free/static; the converter is a runtime host dependency (added to the systemd host setup and the Dockerfile — which therefore uses `debian:bookworm-slim` rather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original `.heic` is stored byte-identical and served for download with its real content type. The sniffer recognizes `ftyp` brands `heic|heix|hevc|hevx|mif1|msf1`.
|
||||
- AVIF: accepted at upload only if decodable; stdlib has no AVIF decoder, so in practice AVIF is rejected with an explicit message (can ride the same converter escape hatch later if needed).
|
||||
|
||||
**Variants** (originals stored byte-identical, never touched):
|
||||
|
||||
| Variant | Long edge | Format | Quality | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `thumb` | 512 px | JPEG | 78 | grid |
|
||||
| `preview` | 2048 px | JPEG | 85 | lightbox |
|
||||
| `original` | — | as uploaded | — | download |
|
||||
|
||||
Re-encoding variants strips EXIF (including GPS — a privacy win for published pages); orientation is applied to pixels first. `width/height/taken_at` recorded on the row from the original.
|
||||
|
||||
**Queue/retry:** the `photos.photos` row is the job. Worker = single goroutine pool (size `WORKER_CONCURRENCY`, default 2) polling with `SELECT ... WHERE status IN ('queued','failed') AND attempts < 5 AND next_attempt_at <= now() ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED`, plus an in-process channel nudge after each upload so the poll interval (10 s) is only a fallback. On error: `attempts+1`, exponential `next_attempt_at`, `last_error`; after 5 attempts stays `failed` and the admin UI shows a retry button (`/photos/:id/retry`). `FOR UPDATE SKIP LOCKED` makes multi-instance safe for free. Crash recovery: on startup, rows stuck in `processing` older than 10 min are re-queued.
|
||||
|
||||
## 7. Access control matrix
|
||||
|
||||
Caller types: **anon** (no/invalid JWT), **member** (valid JWT, any role), **ticket-holder** (member with paid ticket for the gallery's linked event), **link-holder** (anyone presenting the gallery's `share_token`), **admin** (JWT role `admin|organizer`).
|
||||
|
||||
| Visibility | anon | member | ticket-holder | link-holder | admin |
|
||||
|---|---|---|---|---|---|
|
||||
| `public` | ✅ view/download | ✅ | ✅ | ✅ | ✅ + manage |
|
||||
| `private` | ❌ 404 | ❌ 404 | ❌ 404 | ❌ 404 (token ignored) | ✅ + manage |
|
||||
| `link` | ❌ 404 without token | ❌ 404 without token | ❌ 404 without token | ✅ with valid token | ✅ + manage |
|
||||
| `ticket` | ❌ 401 | ❌ 403 | ✅ | valid token also grants ✅ (link is a superset escape hatch; token exists on every gallery but is only honored for `link` and `ticket` modes) | ✅ + manage |
|
||||
|
||||
- Listing: only `public` galleries ever appear in `/api/photos/public/galleries`. `link`/`ticket`/`private` are unlisted; non-public misses return **404, not 403**, so gallery existence isn't probeable (exception: `ticket` mode returns 401/403 so the frontend can prompt login/purchase — the gallery's existence is intentionally discoverable via its event page).
|
||||
- Enforcement lives in **one function**, `store.Authorize(ctx, gallery, caller, token)`, called in exactly two places: the gallery-view handler and the **file handler** — every byte served re-checks; there are no capability URLs except the explicit share token, and S3 presigned URLs are short-lived (15 min) so a leaked presign expires.
|
||||
- **Ticket check** (the `ticket` row + `payments` join, per how "paid" actually works here):
|
||||
|
||||
```sql
|
||||
SELECT 1
|
||||
FROM tickets t
|
||||
JOIN events e ON e.id = t.event_id
|
||||
LEFT JOIN payments p ON p.ticket_id = t.id
|
||||
WHERE t.user_id = ? AND t.event_id = ?
|
||||
AND t.status IN ('confirmed','checked_in')
|
||||
AND (p.status = 'paid' OR e.price = 0) -- review decision: free events need only a confirmed ticket
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
Chosen over the alternative (forwarding the user's JWT to the backend's existing `GET /api/dashboard/tickets`, `backend/src/routes/dashboard.ts:98`) because: we are already in the same database for our own tables, it is one indexed query instead of an HTTP hop + JSON parse, it has no runtime dependency on the backend being up, and it needs zero backend changes. The cost is read-coupling to four `public` columns (`tickets.user_id/event_id/status`, `payments.ticket_id/status`) — mitigated by confining every `public.*` query to `internal/store/access.go` and a startup sanity check that those columns exist (fail loud, not wrong).
|
||||
- **JWT verification** in Go: HS256 + `iss=spanglish` + `aud=spanglish-app`, then one `SELECT role, token_version, account_status FROM public.users WHERE id=$1` to enforce the same revocation semantics as `getAuthUser` (`backend/src/lib/auth.ts:320-327`). Skipping the DB check would silently weaken logout-everywhere/suspension for photo routes; since we're in the DB anyway, we match the backend exactly.
|
||||
|
||||
## 8. Frontend work
|
||||
|
||||
New files (all following existing patterns — `fetchApi`, `useLanguage().t`, Tailwind theme, `ui/` primitives):
|
||||
|
||||
- `src/lib/api/photos.ts` — API module (types + calls for every endpoint in §3), re-exported from `src/lib/api/index.ts`.
|
||||
- **Admin** — `src/app/admin/photos/page.tsx`: gallery list (cards: cover thumb, title, visibility badge, event, count; create-gallery modal). `src/app/admin/photos/[id]/page.tsx`: single gallery manager — multi-file upload (input + drag-drop, per-file progress, `status` polling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists via `PATCH .../order`), visibility selector, event linker (reuses events list API), copy-share-link button (`navigator.clipboard`, `react-hot-toast`), set-cover, delete, retry-failed. Modeled on `admin/gallery/page.tsx` + `admin/events/[id]/` structurally.
|
||||
- **Public** — `src/app/(public)/photos/page.tsx`: server component (fetch public index, `revalidate: 300`, metadata) → `PhotosIndexClient.tsx` grid. `src/app/(public)/photos/[slug]/page.tsx`: server component fetching the gallery *without* token (public galleries get SSR/SEO; link/ticket galleries fall through) → `GalleryClient.tsx` which re-fetches client-side with `?token=` from `useSearchParams()` and the Bearer token, and renders: masonry-ish thumb grid (`ImageGridSkeleton` while loading), lightbox (new shared `src/components/Lightbox.tsx` — full-screen `fixed inset-0 bg-black/90`, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal in `admin/gallery/page.tsx`), download-original per photo, ticket-mode gates (401 → login redirect with `?redirect=`, 403 → "this gallery is for attendees" message linking the event).
|
||||
- i18n: new `photos.*` and `admin.nav.photos` keys in `src/i18n/locales/{en,es}.json`.
|
||||
|
||||
Existing files changed (complete list):
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/app/admin/layout.tsx` | one entry in `navigationWithRoles`: `{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin','organizer'] }` (CameraIcon, since PhotoIcon is taken by the existing Gallery item) |
|
||||
| `next.config.js` | prepend rewrite `{ source: '/api/photos/:path*', destination: PHOTO_API_URL + '/api/photos/:path*' }` **before** the generic `/api/:path*` rule (rewrites are order-sensitive); `PHOTO_API_URL` default `http://localhost:3003` |
|
||||
| `src/lib/api/index.ts` | re-export `photos` module |
|
||||
| `src/i18n/locales/en.json`, `es.json` | new keys |
|
||||
| `src/app/sitemap.ts` | add `/photos` + public gallery slugs (nice-to-have, phase 7) |
|
||||
|
||||
Naming note: the admin nav will then contain both **"Gallery"** (existing homepage-media page at `/admin/gallery`) and **"Photos"** (new). Requirement says "Photos"; flagged in Questions since admins may find the pair confusing.
|
||||
|
||||
## 9. Deployment and config
|
||||
|
||||
New files in `deploy/` (mirroring existing ones; no existing file is *replaced* — the two nginx confs get additive location blocks):
|
||||
|
||||
- `deploy/spanglish-photos.service` — copy of the backend unit shape: user `spanglish`, `ExecStartPre=…/photo-api/bin/photo-api migrate`, `ExecStart=…/photo-api/bin/photo-api`, `EnvironmentFile=…/photo-api/.env`, `PORT=3020`, hardening incl. `ReadWritePaths=…/photo-api/data`.
|
||||
- `deploy/spanglish_upstreams.conf` — add `upstream spanglish_photos { server 127.0.0.1:3020; }`.
|
||||
- `deploy/front-end_nginx.conf` — add, above `location /api`: `location ^~ /api/photos/ { proxy_pass http://spanglish_photos; client_max_body_size 100m; proxy_request_buffering off; }` (uploads are big; the vhost default is 20m). Same block in `deploy/back-end_nginx.conf` if the api subdomain should serve it too.
|
||||
- `photo-api/Dockerfile` — secondary/container path: `golang:1.26` build stage (`CGO_ENABLED=0`), `gcr.io/distroless/static` runtime; and a commented `photos` service example for `deploy/docker-compose.scale.yml` (compose file itself untouched until that path is actually used).
|
||||
|
||||
`photo-api/.env.example`:
|
||||
|
||||
```bash
|
||||
PORT=3003 # dev; systemd unit overrides to 3020
|
||||
DB_TYPE=postgres # postgres | sqlite — must match the backend's engine
|
||||
DATABASE_URL=postgresql://... # SAME database as backend (photos_* tables created by migrate);
|
||||
# for sqlite: path to the backend's db file, e.g. ../backend/data/spanglish.db
|
||||
JWT_SECRET= # MUST equal backend/.env JWT_SECRET
|
||||
HEIC_CONVERTER= # optional; default autodetects `vips`, then `heif-convert`
|
||||
STORAGE_PATH=./data/photos # local storage root (ignored when S3 is on)
|
||||
S3_ENDPOINT= # + S3_BUCKET both set => S3 backend (same convention as backend)
|
||||
S3_REGION=auto
|
||||
S3_BUCKET= # use a photos-specific bucket
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
MAX_UPLOAD_MB=50 # per file
|
||||
WORKER_CONCURRENCY=2
|
||||
FRONTEND_URL=https://spanglishcommunity.com # for building share links
|
||||
```
|
||||
|
||||
Frontend `.env` gains `PHOTO_API_URL=http://localhost:3003` (dev; prod nginx routes directly so the rewrite is unused there, matching how `/api` works today).
|
||||
|
||||
## 10. Build sequence
|
||||
|
||||
1. **Scaffold + DB.** `go.mod`, config, `cmd/photo-api` with `serve`/`migrate`, migration runner + `0001_init.sql`, `/health`, root `package.json` scripts. *Test: `photo-api migrate` creates `photos.*` against the dev DB; `psql \dn` shows the schema; re-run is a no-op; `drizzle-kit generate` still produces no diff.*
|
||||
2. **Auth + gallery CRUD.** JWT middleware (incl. `token_version` check), all `/galleries` admin endpoints. *Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips.*
|
||||
3. **Upload + local storage + worker.** Multipart handler, sniffing, `LocalStorage`, imaging pipeline, queue/retry, `files/:id/:variant` for admins. *Test: upload a batch of real photos incl. a portrait-orientation iPhone JPEG (EXIF rotation) and a deliberately corrupt file; variants appear; corrupt file lands in `failed` with `last_error`; retry works.*
|
||||
4. **Access control + public endpoints.** `Authorize`, ticket-check query, `public/galleries`, `public/galleries/:slug`, token handling, presign-vs-stream in file handler. *Test: matrix in §7 as a Go table test against seeded DB rows (paid/pending/refunded payments), plus curl for each caller type.*
|
||||
5. **S3 backend.** `S3Storage` + presigned GETs, verified against a local MinIO/Garage. *Test: same upload/download flows with `S3_*` set; private gallery file URL fails without presign.*
|
||||
6. **Admin frontend.** `/admin/photos` pages, nav item, api module, i18n. *Test: create → upload → reorder → visibility → copy link, in-browser against dev services.*
|
||||
7. **Public frontend.** `/photos`, `/photos/[slug]`, Lightbox, download, gated states, sitemap. *Test: all four visibility modes in-browser, incl. link URL in an incognito window and ticket mode with a paid vs pending test user.*
|
||||
8. **Deploy.** systemd unit, nginx blocks, `.env.example`s, Dockerfile, README section in `photo-api/`. *Test: on the host — `systemctl start spanglish-photos`, nginx reload, end-to-end over HTTPS.*
|
||||
|
||||
## 11. Risks and open questions
|
||||
|
||||
**Risks / could-not-determine:**
|
||||
|
||||
- **Production DB engine is unverifiable from the repo** (local `backend/.env` says `DB_TYPE=postgres`; prod env is gitignored). Mitigated by the review decision to support both engines — whichever prod runs, photo-api points at the same database with matching `DB_TYPE`. On SQLite, two processes share one file: WAL is already on, photo-api adds `busy_timeout` and short write transactions, but write contention under heavy simultaneous booking + photo-processing load is a residual (small) risk.
|
||||
- **Schema read-coupling.** The ticket/user queries read five `public` columns Drizzle owns. A future rename breaks photo access checks at runtime; mitigated by the startup column sanity-check (fail fast) and by the queries living in one file.
|
||||
- **Free events.** If a PYG-0 event issues `confirmed` tickets whose payment row is not `'paid'` (e.g. no payment or `cash`/`pending`), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes into `payments`. May need `OR (e.price = 0 AND t.status IN ('confirmed','checked_in'))`.
|
||||
- **Guest attendees** (`tickets.is_guest`, tickets bought by someone else): access is keyed on `tickets.user_id`, so a +1 without their own account cannot pass `ticket` gating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge.
|
||||
- **HEIC depends on a host-installed converter** (`vips` or `heif-convert`). If the prod host lacks the package, HEIC uploads 415 until it's installed; startup logs a warning so this is visible, and the deploy notes include the `apt install libvips-tools` step.
|
||||
- **Nginx body size / timeouts** for 100 MB batch uploads over spotty Paraguayan mobile connections; `proxy_request_buffering off` planned, but real-world testing is in phase 8.
|
||||
- **1-day JWT expiry with no working refresh flow** (refresh tokens are minted but no endpoint consumes them) means long admin upload sessions can hit 401 mid-batch; the admin UI must surface that as "session expired, log in again" rather than a generic failure.
|
||||
|
||||
**Questions — answered in review 2026-07-24** (see the revision note at the top): dual Postgres+SQLite support; systemd-first confirmed; free events count any confirmed ticket; nav name "Photos" kept; HEIC supported via converter shell-out; `admin`/`organizer` only; local disk and S3 both supported from day 1; the service keeps its own `.env`. The production engine check (and, if Postgres, connectivity for the `spanglish` role) is still on the user's side.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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
|
||||
Generated
+763
@@ -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
|
||||
Reference in New Issue
Block a user