Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
import { execFile } from 'node:child_process'
|
||
import { readFile, unlink, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import path from 'node:path'
|
||
import { promisify } from 'node:util'
|
||
import { encode as encodeBlurhash } from 'blurhash'
|
||
import sharp from 'sharp'
|
||
import { THUMB_MAX_EDGE } from '@/lib/files/limits'
|
||
import type { AllowedType } from '@/lib/files/magic'
|
||
|
||
const run = promisify(execFile)
|
||
|
||
/**
|
||
* Neu kodieren statt durchreichen.
|
||
*
|
||
* Das ist die wirksamste Massnahme der ganzen Kette. Ein Bild, das durch
|
||
* `sharp` gelaufen ist, besteht nur noch aus Pixeln: EXIF, Farbprofile,
|
||
* angehängte Nutzlast und alles, was ein Betrachter versehentlich auswerten
|
||
* könnte, sind weg - nicht gefiltert, sondern nie übernommen.
|
||
* Siehe docs/07-dateien-und-sicherheit.md.
|
||
*/
|
||
|
||
export type Reencoded = {
|
||
content: Uint8Array
|
||
mimeType: AllowedType
|
||
width?: number
|
||
height?: number
|
||
thumb?: Uint8Array
|
||
blurhash?: string
|
||
}
|
||
|
||
export type ReencodeResult =
|
||
| { ok: true; result: Reencoded }
|
||
| { ok: false; reason: string }
|
||
|
||
/** Grösser als das kodiert kein Bild, das noch ein Bild sein will. */
|
||
const MAX_PIXELS = 50_000_000
|
||
|
||
export async function reencode(
|
||
content: Uint8Array,
|
||
type: AllowedType,
|
||
): Promise<ReencodeResult> {
|
||
if (type === 'text/plain') return reencodeText(content)
|
||
if (type === 'application/pdf') return sanitisePdf(content)
|
||
return reencodeImage(content, type)
|
||
}
|
||
|
||
async function reencodeImage(
|
||
content: Uint8Array,
|
||
type: Exclude<AllowedType, 'text/plain' | 'application/pdf'>,
|
||
): Promise<ReencodeResult> {
|
||
try {
|
||
// `limitInputPixels` schützt gegen die Bildbombe: wenige Kilobyte Datei,
|
||
// Gigabyte im Speicher.
|
||
const quelle = sharp(content, { limitInputPixels: MAX_PIXELS, animated: false })
|
||
const info = await quelle.metadata()
|
||
if (!info.width || !info.height) return { ok: false, reason: 'Kein lesbares Bild' }
|
||
|
||
// `.rotate()` ohne Winkel wendet die EXIF-Ausrichtung an und wirft sie
|
||
// danach weg. Ohne das läge das Bild später auf der Seite.
|
||
const basis = sharp(content, { limitInputPixels: MAX_PIXELS, animated: false }).rotate()
|
||
|
||
const format = type === 'image/png' ? 'png' : type === 'image/webp' ? 'webp' : 'jpeg'
|
||
const { data, info: erzeugt } = await basis
|
||
.clone()
|
||
.toFormat(format, format === 'jpeg' ? { quality: 88, mozjpeg: true } : {})
|
||
.toBuffer({ resolveWithObject: true })
|
||
|
||
const thumb = await sharp(data, { limitInputPixels: MAX_PIXELS })
|
||
.resize(THUMB_MAX_EDGE, THUMB_MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
|
||
.toFormat('webp', { quality: 80 })
|
||
.toBuffer()
|
||
|
||
return {
|
||
ok: true,
|
||
result: {
|
||
content: data,
|
||
mimeType: type,
|
||
width: erzeugt.width,
|
||
height: erzeugt.height,
|
||
thumb,
|
||
blurhash: await blurhashOf(data),
|
||
},
|
||
}
|
||
} catch (error) {
|
||
return { ok: false, reason: `Bild nicht neu kodierbar: ${nachricht(error)}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ein Platzhalterbild aus wenigen Zeichen, damit die Seite nicht springt,
|
||
* während das Bild lädt. Schlägt es fehl, ist das kein Grund, die Datei
|
||
* abzulehnen.
|
||
*/
|
||
async function blurhashOf(image: Uint8Array): Promise<string | undefined> {
|
||
try {
|
||
const { data, info } = await sharp(image)
|
||
.raw()
|
||
.ensureAlpha()
|
||
.resize(32, 32, { fit: 'inside' })
|
||
.toBuffer({ resolveWithObject: true })
|
||
return encodeBlurhash(new Uint8ClampedArray(data), info.width, info.height, 4, 3)
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Text auf UTF-8 normalisieren. Eine Stückliste am Anfang fliegt raus, weil
|
||
* sie beim Anzeigen als sichtbares Zeichen durchschlägt.
|
||
*/
|
||
function reencodeText(content: Uint8Array): ReencodeResult {
|
||
try {
|
||
const text = new TextDecoder('utf-8', { fatal: true }).decode(content).replace(/^/, '')
|
||
return {
|
||
ok: true,
|
||
result: { content: new TextEncoder().encode(text), mimeType: 'text/plain' },
|
||
}
|
||
} catch {
|
||
return { ok: false, reason: 'Kein gültiges UTF-8' }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* PDF säubern.
|
||
*
|
||
* `--empty --pages datei 1-z --` baut das Dokument aus den Seiten neu auf.
|
||
* Der ursprüngliche Dokumentkatalog wird dabei nicht übernommen, und mit ihm
|
||
* verschwinden `OpenAction`, eingebettetes JavaScript und angehängte Dateien -
|
||
* die drei Wege, auf denen ein PDF etwas tut, statt nur dazustehen.
|
||
*
|
||
* Fehlt `qpdf`, wird die Datei **nicht** durchgewinkt. Sie bleibt in der
|
||
* Quarantäne, und ein Administrator kann sie bewusst freigeben.
|
||
*/
|
||
async function sanitisePdf(content: Uint8Array): Promise<ReencodeResult> {
|
||
const kennung = `afis-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||
const quelle = path.join(tmpdir(), `${kennung}-in.pdf`)
|
||
const ziel = path.join(tmpdir(), `${kennung}-out.pdf`)
|
||
|
||
try {
|
||
await writeFile(quelle, content)
|
||
await run('qpdf', ['--decrypt', '--empty', '--pages', quelle, '1-z', '--', ziel], {
|
||
timeout: 30_000,
|
||
})
|
||
return { ok: true, result: { content: await readFile(ziel), mimeType: 'application/pdf' } }
|
||
} catch (error) {
|
||
const grund =
|
||
(error as NodeJS.ErrnoException)?.code === 'ENOENT'
|
||
? 'qpdf ist nicht installiert, PDF wurde nicht gesäubert'
|
||
: `PDF nicht säuberbar: ${nachricht(error)}`
|
||
return { ok: false, reason: grund }
|
||
} finally {
|
||
await Promise.all([unlink(quelle).catch(() => undefined), unlink(ziel).catch(() => undefined)])
|
||
}
|
||
}
|
||
|
||
function nachricht(error: unknown): string {
|
||
if (error instanceof Error) return error.message.split('\n')[0]!
|
||
return 'unbekannt'
|
||
}
|