fix(app): proxy photo opaque + EXIF strip + WebP (C4), radius_eff communes immenses (A1), variante no-frwiki (A3), cache wiki 24h
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
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'
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user