Files
Spanglish/frontend/src/lib/api/photos.ts
T
MichilisandClaude Opus 5 733d2459df Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:04 +00:00

178 lines
5.7 KiB
TypeScript

import { fetchApi, API_BASE } 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 formData = new FormData();
formData.append('files', file);
// Auth rides on the session cookie (sent same-origin automatically)
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST',
credentials: API_BASE ? 'include' : 'same-origin',
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`);
// Auth rides on the session cookie; XHR sends same-origin cookies by
// default, withCredentials is only needed for a cross-origin API_BASE.
if (API_BASE) xhr.withCredentials = true;
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}`
);
},
};