Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.4 kB Ruw bestand
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() } };
}