Files
RecipesCam/docker/frontend/src/api.ts
T

357 lines
16 KiB
TypeScript

// Thin wrapper over the API container. Same origin in production (nginx proxies
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
import type { Recipe } from '../shared/types';
import { shrinkForUpload, resizedJpeg } from './engine/imageOps';
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
// browser first; the server then sniffs the bytes and requires the declared
// content-type to match, which is why the type follows the re-encode below.
const MAX_PHOTO_UPLOAD = 12 * 1024 * 1024;
const MAX_PHOTO_DIM = 2048;
const MAX_AVATAR_DIM = 512;
export interface User {
id: number;
email: string;
// True when the account is on the API's ADMIN_EMAILS allowlist. The server
// re-checks it on every admin route; this only drives what the UI offers.
admin?: boolean;
// Signed-in accounts only unlock the PRO tier once their address is proven;
// an unverified one is served exactly like a guest. Admins count as verified.
verified?: boolean;
// The PRO tier itself: the proof above, or a grant the operator ticked for
// this account in the admin users table.
pro?: boolean;
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
// The version segment is the file's own name, so a replacement is never
// served from cache.
avatar?: string | null;
}
export interface SavedRecipe {
id: number;
name: string;
recipe: unknown;
createdAt: string;
updatedAt: string;
}
// Where a curated photo may appear on the landing page. `strip` is the
// community reel; the rest are the three live sections, each of which shows one
// random photo out of its set per page load. A photo may sit in several at
// once; an empty set is the curator's removal from the landing.
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
// A strip contribution as the public sees it — the API never puts an email on
// this shape. `tag`/`title`/`meta` are the frame's own labels (the amber
// tagline, the artwork title and the `ISO … · GRAIN …` line); null when the
// uploader sent none, and the reel then falls back to its built-in text.
export interface Photo {
id: number;
createdAt: string;
slots: PhotoSlot[];
tag: string | null;
title: string | null;
meta: string | null;
// The uploader's consent to show this frame on the landing film strip. Own
// rows carry it back; the public reel only returns consented ones anyway.
consent?: boolean;
// Whether the row still carries the look that made it. That look is the QR
// card's payload, so the admin needs to know a photo can hand one out without
// the listing shipping the recipe itself.
hasPreset: boolean;
// The look the photo was saved with, on the owner's own listing only — the
// studio reads it back so a saved photo can be reopened for editing.
recipe?: Recipe | null;
// The looks that photo carried before its last saves, newest first and capped
// at 3 by the API. Owner's own listing only; absent means "never re-saved".
history?: Recipe[];
}
// What a caller may attach to an upload. Same three labels.
export interface PhotoLabels {
tag?: string;
title?: string;
meta?: string;
}
// Admin listing only: adds the owner, which /api/admin/photos is gated on.
export interface AdminPhoto extends Photo {
userId: number;
email: string;
mime: string;
bytes: number;
// The name of the look the photo was saved with, or null — the admin's A→Z
// ordering key for the album strip.
recipeName: string | null;
}
// One film-strip frame's score. `avg` is the mean of every vote (0 when nobody
// has voted), `n` how many there were, and `mine` this visitor's own vote (0
// when they have not rated it) — what the landing's star row is drawn from.
export interface Rating {
avg: number;
n: number;
mine: number;
}
// One frame of the landing hero's award column: what a window of votes left on
// one photo. `at` is the most recent of them, the API's own last tie-break; the
// labels and the file come from the public listing, keyed by `id`.
export interface PhotoTally {
id: number;
avg: number;
n: number;
at: string;
}
// The windows the award column reads: `day` is today's votes, `week` this ISO
// week's — so today's winners appear in both, and the column shows them once,
// as the day's. `ever` is every vote ever cast, and it is filled only when both
// windows came back empty: with nothing to name a frame today or this week, the
// best of all time is the only honest answer the column has left.
export interface Highlights {
day: PhotoTally[];
week: PhotoTally[];
ever: PhotoTally[];
}
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
// (or null), same shape as on the signed-in user.
export interface AdminUser {
id: number;
email: string;
createdAt: string;
photos: number;
admin: boolean;
avatar: string | null;
// Moderation state: blocked = cannot sign in; removed = pulled off the site
// but restorable (a hard delete drops the row entirely).
blocked: boolean;
removed: boolean;
// The "Activated Pro" box: true hands this account the studio's PRO tier.
pro: boolean;
}
// One row of a grouped count on the traffic screen. The API sorts them, largest
// first, and drops the empty ones.
export interface StatBucket {
key: string;
n: number;
}
// What /api/admin/stats reports: the day series the chart draws, plus one
// grouped breakdown per dimension. Admin only.
export interface Stats {
days: number;
totals: { views: number; clicks: number; visitors: number };
series: { date: string; views: number; clicks: number }[];
pages: StatBucket[];
targets: StatBucket[];
countries: StatBucket[];
regions: StatBucket[];
cities: StatBucket[];
browsers: StatBucket[];
systems: StatBucket[];
devices: StatBucket[];
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: 'same-origin',
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
...init,
});
if (res.status === 204) return undefined as T;
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as T;
}
// What an upload (or a re-save) may carry beside the bytes, on the query string
// because the body is the image itself.
type PhotoMetaOpts = { recipe?: Recipe; consent?: boolean };
// The frame's labels and its look, as the query suffix the photo routes read.
// `?` is left on the caller: an empty one is a valid URL either way.
function photoQuery(labels?: PhotoLabels, opts?: PhotoMetaOpts): string {
const q = new URLSearchParams();
if (labels?.tag) q.set('tag', labels.tag);
if (labels?.title) q.set('title', labels.title);
if (labels?.meta) q.set('meta', labels.meta);
// The look rides the query string too, so the photo reopens with its own
// settings. The server caps the length and drops anything not an object.
if (opts?.recipe) q.set('recipe', JSON.stringify(opts.recipe));
if (opts?.consent === false) q.set('consent', '0');
return q.size > 0 ? `?${q}` : '';
}
// A gateway error (nginx's 502/504 page) arrives as HTML, and the JSON parser's
// "Unexpected token '<'" says nothing useful — degrade to the status instead.
async function readJson(res: Response): Promise<{ error?: string } & Record<string, unknown>> {
const text = await res.text();
if (!text) return {};
try {
return JSON.parse(text) as { error?: string };
} catch {
return { error: `HTTP ${res.status}` };
}
}
// POST a picture as raw bytes, after shrinking it under the server's caps.
// Declared type stays the original unless the bytes were re-encoded to JPEG —
// the server compares it against the sniffed magic number.
async function uploadBytes(
url: string,
file: File,
maxDim: number,
maxBytes: number,
method: 'POST' | 'PUT' = 'POST',
): Promise<Response> {
const bytes = new Uint8Array(await file.arrayBuffer());
const out = await shrinkForUpload(bytes, maxDim, maxBytes);
return fetch(url, {
method,
credentials: 'same-origin',
headers: { 'content-type': out === bytes ? file.type || 'image/jpeg' : 'image/jpeg' },
body: out as unknown as BodyInit,
});
}
export const api = {
// null user = signed out; the API answers 200 either way.
me: () => call<{ user: User | null }>('/auth/me'),
signup: (email: string, password: string) =>
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
login: (email: string, password: string) =>
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => call<void>('/auth/logout', { method: 'POST' }),
// Mail the verification link to the signed-in address again. Works while
// unverified (that is the whole point); 429 once the hourly cap is spent.
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
// The six digits from the same letter, typed instead of clicking the link.
// The route answers to the session the signup already granted, so the code
// proves the address to the browser that asked for it.
verifyCode: (code: string) =>
call<{ ok: boolean; verified?: boolean }>('/auth/verify-code', { method: 'POST', body: JSON.stringify({ code }) }),
listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'),
createRecipe: (name: string, recipe: Recipe) =>
call<{ recipe: SavedRecipe }>('/recipes', { method: 'POST', body: JSON.stringify({ name, recipe }) }),
updateRecipe: (id: number, name: string, recipe: Recipe) =>
call<{ recipe: SavedRecipe }>(`/recipes/${id}`, { method: 'PUT', body: JSON.stringify({ name, recipe }) }),
deleteRecipe: (id: number) => call<void>(`/recipes/${id}`, { method: 'DELETE' }),
// The strip. Upload is the raw file as the request body — one image per
// request, so no multipart framing and no extra dependency. The frame's
// labels ride the query string, since the body is the image itself.
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
// The name of a coordinate, for the stamp. Resolved server-side (the browser
// has no OS geocoder), so a hit is a network round trip; null = no name.
place: (lat: number, lng: number) => call<{ place: string | null }>(`/place?lat=${lat}&lng=${lng}`),
// Film-strip ratings, keyed by subject (`photo:<id>` or `look:<TAG>`). Public:
// any visitor may read the tallies and cast one vote per frame.
ratings: () => call<{ ratings: Record<string, Rating> }>('/ratings'),
rate: (key: string, stars: number) =>
call<{ key: string; rating: Rating }>('/ratings', { method: 'POST', body: JSON.stringify({ key, stars }) }),
// The hero's award column: the best-rated contributions of the UTC day and of
// the ISO week, most deserving first. Public, same as the tally it is read
// off; the frames' own labels come from the public listing.
highlights: () => call<{ highlights: Highlights }>('/highlights'),
uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { photo: Photo };
},
// Re-saving a photo the account already owns: the bytes replace the row's own
// and the look it carried moves into its history. Owner-only, same shape as
// the upload.
replacePhoto: async (id: number, file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
const res = await uploadBytes(`/api/photos/${id}${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD, 'PUT');
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { photo: Photo & { history: Recipe[] } };
},
// Toggle this photo's place on the landing film strip. Owner-only.
setPhotoConsent: (id: number, consent: boolean) =>
call<{ id: number; consent: boolean }>(`/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ consent }) }),
// The owner's own delete; an admin may pass any id here too.
deletePhoto: (id: number) => call<void>(`/photos/${id}`, { method: 'DELETE' }),
photoUrl: (id: number) => `/api/photos/${id}/file`,
// The editable base beside a saved render — the pixels the look was applied
// to, written right after the photo itself. Opening a saved photo loads this,
// so its look lands on the original instead of a second time on its own
// output. 404 for a photo saved before the base existed.
photoBaseUrl: (id: number) => `/api/photos/${id}/base`,
putPhotoBase: async (id: number, bytes: Uint8Array) => {
// Always JPEG, always inside an upload's caps: the server compares the
// declared type against the bytes' magic number, and a base is only ever
// read back by the engine (`0` = no byte budget, so a copy already inside
// `MAX_PHOTO_DIM` is still re-encoded rather than passed through).
const out = await shrinkForUpload(bytes, MAX_PHOTO_DIM, 0);
const res = await fetch(`/api/photos/${id}/base`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'content-type': 'image/jpeg' },
body: out as unknown as BodyInit,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
},
// The look a photo was uploaded with, as the `.recipe` file the app imports on
// a scan. Only answers for a photo the curator ticked into the `qr` section.
presetUrl: (id: number) => `/api/photos/${id}/preset.recipe`,
// Admin only: every account, with how many photos it owns.
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
// The traffic screen. `days` is clamped server-side, never rejected.
adminStats: (days: number) => call<Stats>(`/admin/stats?days=${days}`),
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
adminSetPhotoSlots: (id: number, slots: PhotoSlot[]) =>
call<{ id: number; slots: PhotoSlot[] }>(`/admin/photos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ slots }),
}),
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
// the final act and takes the account's photos and recipes with it.
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
// The whole data dir (SQLite + media) as one tar.gz, and the route that takes
// the same file back. The download is a plain link — the session cookie rides
// along — so nothing here fetches it.
adminBackupUrl: () => '/api/admin/backup',
// Raw bytes as the body, like a photo upload: the archive is already
// compressed, so no multipart wrapper and nothing to re-encode. The API
// replaces its data and restarts itself, so a 200 means "come back shortly".
adminRestore: async (file: File) => {
const res = await fetch('/api/admin/restore', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/gzip' },
body: file,
});
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { ok: boolean };
},
// Own profile. The API wants `currentPassword` on every edit, even an email-only one.
updateProfile: (body: { email?: string; password?: string; currentPassword: string }) =>
call<{ user: User }>('/auth/me', { method: 'PATCH', body: JSON.stringify(body) }),
// The profile picture, raw bytes like a photo. Replacing it deletes the old
// file, so the returned User carries a new `?v=` and nothing goes stale.
uploadAvatar: async (file: File) => {
const res = await uploadBytes('/api/auth/avatar', file, MAX_AVATAR_DIM, MAX_PHOTO_UPLOAD);
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { user: User };
},
};