Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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() } };
}