import { PHOTO_MAX_BYTES, PHOTO_MIMES, PHOTO_ALT_MAX } from "../src/constants.js"; /* * De foto bij een brief, aan de kant van de server. De app stuurt hem mee in de JSON van * POST /api/letters als { data (base64), mime, width, height, alt } — één brief, één foto, één * transactie, dus nooit een foto zonder brief. * * Wat hier gecontroleerd wordt, controleert de app niet nog eens voor ons: de bytes moeten écht een * afbeelding zijn van het opgegeven type. Een bestand dat zich voordoet als jpeg maar html blijkt, * wordt anders door een browser als html uitgevoerd zodra iemand de mediaserver rechtstreeks opent. * Vandaar de magische bytes én `X-Content-Type-Options: nosniff` bij het uitleveren. */ /* De eerste bytes van elk toegestaan formaat; twaalf bytes is genoeg voor alle drie */ const SIGNATURES = [ { mime: "image/jpeg", matches: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, { mime: "image/png", matches: (b) => PNG_MAGIC.every((v, i) => b[i] === v) }, { mime: "image/webp", matches: (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP" }, ]; const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; const ascii = (b, from, to) => b.subarray(from, to).toString("latin1"); /* Welk formaat zijn deze bytes werkelijk? Null als het geen van de toegestane is */ export const sniffMime = (bytes) => bytes.length >= 12 ? (SIGNATURES.find((s) => s.matches(bytes))?.mime ?? null) : null; /* * En hoe groot is hij werkelijk? Ook dit stond in het verzoek en werd geloofd, terwijl het net als * de mime iets is dat in de bytes zelf staat. De afmetingen bepalen de ruimte die de app voor de * foto vrijhoudt vóór hij geladen is; wie er 10000 bij 1 van maakt, verbouwt daarmee de brief van * de ontvanger. Klein leed, maar het is dezelfde soort onwaarheid als een jpeg die html blijkt, en * de bytes hebben het antwoord al bij zich. * * Per formaat op de plek waar het formaat het zegt. Null als het er niet uit te lezen is — dan * weigeren we de foto, net als bij een mime die niet klopt: de app hercodeert zelf naar een gewone * JPEG (zie src/lib/image.js), dus onleesbaar betekent hier iets anders dan "zeldzaam". */ const MATEN = { "image/png": (b) => (b.length >= 24 ? { width: b.readUInt32BE(16), height: b.readUInt32BE(20) } : null), /* JPEG: de segmenten aflopen tot de framekop (SOF), daar staan hoogte en breedte in */ "image/jpeg": (b) => { for (let i = 2; i + 9 < b.length && b[i] === 0xff; i += 2 + b.readUInt16BE(i + 2)) { const soort = b[i + 1]; if (soort >= 0xc0 && soort <= 0xcf && soort !== 0xc4 && soort !== 0xc8 && soort !== 0xcc) return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) }; } return null; }, /* WebP kent drie vormen, elk met de maat op een eigen plek achter de RIFF-kop (payload vanaf 20) */ "image/webp": (b) => { const vorm = ascii(b, 12, 16); if (vorm === "VP8X" && b.length >= 30) return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 }; if (vorm === "VP8L" && b.length >= 25 && b[20] === 0x2f) { const bits = b.readUInt32LE(21); return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; } if (vorm === "VP8 " && b.length >= 30 && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff }; return null; }, }; export function sniffSize(bytes, mime) { let maat = null; try { maat = MATEN[mime]?.(bytes) ?? null; } catch { return null; // een afgekapt bestand leest over het eind heen; dat is gewoon 'onleesbaar' } const goed = (n) => Number.isInteger(n) && n >= 1 && n <= 10000; return maat && goed(maat.width) && goed(maat.height) ? maat : null; } /* base64 zonder witruimte of andere tekens; Buffer.from() negeert rommel stilzwijgend, wij niet */ const isBase64 = (s) => /^[A-Za-z0-9+/]+={0,2}$/.test(s) && s.length % 4 === 0; /* * De foto uit een verzoek nakijken en klaarmaken voor de database. * Geeft { error } met een melding voor de gebruiker, of { photo: { mime, bytes, width, height, alt } }. */ export function checkPhoto(photo) { if (typeof photo !== "object" || photo === null) return { error: "photo moet een object zijn, of null" }; const { data, mime, alt } = photo; if (typeof alt !== "string" || !alt.trim() || alt.trim().length > PHOTO_ALT_MAX) return { error: `Beschrijf kort wat er op de foto staat (maximaal ${PHOTO_ALT_MAX} tekens)` }; if (!PHOTO_MIMES.includes(mime)) return { error: "Dit soort afbeelding kunnen we niet versturen" }; if (typeof data !== "string" || !isBase64(data)) return { error: "De foto kon niet gelezen worden" }; // eerst de lengte van de base64 zelf: zo hoeft een veel te grote foto niet gedecodeerd te worden if (data.length > Math.ceil(PHOTO_MAX_BYTES / 3) * 4) return { error: "De foto is te groot" }; const bytes = Buffer.from(data, "base64"); if (!bytes.length || bytes.length > PHOTO_MAX_BYTES) return { error: "De foto is te groot" }; if (sniffMime(bytes) !== mime) return { error: "Dit bestand is geen foto" }; /* De afmetingen komen uit de bytes, niet uit het verzoek — wat de app meestuurt heeft hij zelf nodig voor het voorbeeld, en wij hoeven het niet te geloven */ const maat = sniffSize(bytes, mime); if (!maat) return { error: "De afmetingen van de foto kloppen niet" }; return { photo: { mime, bytes, width: maat.width, height: maat.height, alt: alt.trim() } }; }