import { createHash } from 'node:crypto' /** * Ein winziger ZIP-Schreiber, nur „gespeichert" ohne Kompression. * * Eigener Code statt einer Abhängigkeit, weil der Inhalt eines Sammelexports * PDFs und bereits neu kodierte Bilder sind — beides ist komprimiert, ein * Deflate darüber bringt nichts und kostet nur CPU auf einem Heimserver. Was * bleibt, ist das Behälterformat, und das sind achtzig Zeilen. * * Aufbau nach der ZIP-Spezifikation: je Eintrag ein lokaler Kopf mit den * Daten, danach das zentrale Verzeichnis und dessen Abschluss. */ type Eintrag = { name: string; content: Uint8Array } /** DOS-Zeitstempel. Die Auflösung beträgt zwei Sekunden, das reicht hier. */ function dosZeit(datum: Date): { zeit: number; datum: number } { return { zeit: (datum.getHours() << 11) | (datum.getMinutes() << 5) | Math.floor(datum.getSeconds() / 2), datum: ((datum.getFullYear() - 1980) << 9) | ((datum.getMonth() + 1) << 5) | datum.getDate(), } } function crc32(daten: Uint8Array): number { // Tabelle einmal je Aufruf: bei einer Handvoll Dateien je Export ist das // billiger als eine Konstante, die immer im Speicher liegt. let c: number const tabelle = new Int32Array(256) for (let n = 0; n < 256; n++) { c = n for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 tabelle[n] = c } let crc = -1 for (const byte of daten) crc = (crc >>> 8) ^ tabelle[(crc ^ byte) & 0xff]! return (crc ^ -1) >>> 0 } export function createZip(eintraege: Eintrag[], now: Date = new Date()): Uint8Array { const { zeit, datum } = dosZeit(now) const teile: Buffer[] = [] const verzeichnis: Buffer[] = [] let offset = 0 for (const eintrag of eintraege) { const name = Buffer.from(eintrag.name, 'utf8') const inhalt = Buffer.from(eintrag.content) const pruefsumme = crc32(eintrag.content) const lokal = Buffer.alloc(30) lokal.writeUInt32LE(0x04034b50, 0) lokal.writeUInt16LE(20, 4) // benötigte Version lokal.writeUInt16LE(0x0800, 6) // Bit 11: Name ist UTF-8 lokal.writeUInt16LE(0, 8) // Verfahren: gespeichert lokal.writeUInt16LE(zeit, 10) lokal.writeUInt16LE(datum, 12) lokal.writeUInt32LE(pruefsumme, 14) lokal.writeUInt32LE(inhalt.length, 18) lokal.writeUInt32LE(inhalt.length, 22) lokal.writeUInt16LE(name.length, 26) lokal.writeUInt16LE(0, 28) teile.push(lokal, name, inhalt) const zentral = Buffer.alloc(46) zentral.writeUInt32LE(0x02014b50, 0) zentral.writeUInt16LE(20, 4) zentral.writeUInt16LE(20, 6) zentral.writeUInt16LE(0x0800, 8) zentral.writeUInt16LE(0, 10) zentral.writeUInt16LE(zeit, 12) zentral.writeUInt16LE(datum, 14) zentral.writeUInt32LE(pruefsumme, 16) zentral.writeUInt32LE(inhalt.length, 20) zentral.writeUInt32LE(inhalt.length, 24) zentral.writeUInt16LE(name.length, 28) zentral.writeUInt32LE(offset, 42) verzeichnis.push(zentral, name) offset += lokal.length + name.length + inhalt.length } const verzeichnisBytes = Buffer.concat(verzeichnis) const abschluss = Buffer.alloc(22) abschluss.writeUInt32LE(0x06054b50, 0) abschluss.writeUInt16LE(eintraege.length, 8) abschluss.writeUInt16LE(eintraege.length, 10) abschluss.writeUInt32LE(verzeichnisBytes.length, 12) abschluss.writeUInt32LE(offset, 16) return new Uint8Array(Buffer.concat([...teile, verzeichnisBytes, abschluss])) } /** Für das Protokoll: worüber genau wurde ausgeleitet. */ export function digestOf(daten: Uint8Array): string { return createHash('sha256').update(daten).digest('hex') }