89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
import sharp from 'sharp';
|
|
import { getLieuById, PHOTO_LARGEURS } from '$lib/server/lieux';
|
|
import type { RequestHandler } from './$types';
|
|
|
|
const USER_AGENT = 'JWE-OKI/2.0 (https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/JWE; contact OKI)';
|
|
const TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
|
|
type Largeur = (typeof PHOTO_LARGEURS)[number];
|
|
const LARGEUR_DEFAUT: Largeur = 1200;
|
|
|
|
/** Cache mémoire : clé `${id}:${w}`, TTL 24 h. */
|
|
const cache = new Map<string, { expires: number; data: Buffer }>();
|
|
/** Déduplication des fetches en vol (même photo demandée en rafale). */
|
|
const enVol = new Map<string, Promise<Buffer | null>>();
|
|
|
|
/**
|
|
* Récupère la photo via l'URL Commons stockée dans le corpus (jamais d'URL
|
|
* fournie par le client — pas de proxy ouvert), puis la retraite avec sharp :
|
|
* - rotate() : applique l'orientation EXIF avant suppression des métadonnées ;
|
|
* - métadonnées : sharp ne conserve AUCUNE métadonnée (EXIF/GPS compris) sauf
|
|
* appel explicite à withMetadata() — le WebP produit est donc propre ;
|
|
* - conversion WebP + redimensionnement à la largeur demandée.
|
|
*/
|
|
async function chargerPhoto(id: string, w: Largeur): Promise<Buffer | null> {
|
|
const lieu = getLieuById(id);
|
|
if (!lieu) return null;
|
|
const res = await fetch(lieu.photo.url, { headers: { 'User-Agent': USER_AGENT } });
|
|
if (!res.ok) throw new Error(`commons ${res.status}`);
|
|
const brut = Buffer.from(await res.arrayBuffer());
|
|
return sharp(brut)
|
|
.rotate()
|
|
.resize({ width: w, withoutEnlargement: true })
|
|
.webp({ quality: 82 })
|
|
.toBuffer();
|
|
}
|
|
|
|
/**
|
|
* GET /api/photo/[id]?w=400|800|1200 → image/webp.
|
|
* `id` = uuid du lieu (déjà exposé par /api/round). 404 si lieu inconnu.
|
|
* L'URL ainsi produite est opaque : elle ne révèle ni le nom du fichier
|
|
* Commons ni le lieu (audit C4).
|
|
*/
|
|
export const GET: RequestHandler = async ({ params, url }) => {
|
|
const id = params.id;
|
|
const wParam = url.searchParams.get('w');
|
|
let w: Largeur = LARGEUR_DEFAUT;
|
|
if (wParam !== null) {
|
|
const n = Number(wParam);
|
|
if (!(PHOTO_LARGEURS as readonly number[]).includes(n)) {
|
|
return new Response('Largeur invalide (400, 800 ou 1200)', { status: 400 });
|
|
}
|
|
w = n as Largeur;
|
|
}
|
|
|
|
const cle = `${id}:${w}`;
|
|
const hit = cache.get(cle);
|
|
if (hit && hit.expires > Date.now()) {
|
|
return reponseWebp(hit.data);
|
|
}
|
|
|
|
// 404 immédiat si l'id est inconnu (avant tout fetch amont).
|
|
if (!getLieuById(id)) {
|
|
return new Response('Lieu inconnu', { status: 404 });
|
|
}
|
|
|
|
try {
|
|
let promesse = enVol.get(cle);
|
|
if (!promesse) {
|
|
promesse = chargerPhoto(id, w).finally(() => enVol.delete(cle));
|
|
enVol.set(cle, promesse);
|
|
}
|
|
const data = await promesse;
|
|
if (!data) return new Response('Lieu inconnu', { status: 404 });
|
|
cache.set(cle, { expires: Date.now() + TTL_MS, data });
|
|
return reponseWebp(data);
|
|
} catch {
|
|
return new Response('Photo indisponible', { status: 502 });
|
|
}
|
|
};
|
|
|
|
function reponseWebp(data: Buffer): Response {
|
|
return new Response(new Uint8Array(data), {
|
|
headers: {
|
|
'Content-Type': 'image/webp',
|
|
'Cache-Control': 'public, max-age=86400, immutable'
|
|
}
|
|
});
|
|
}
|