From 7d539625437707e05c304380e9ffa4aa8f17e2ff Mon Sep 17 00:00:00 2001
From: Till Heidrich
Date: Tue, 1 Sep 2026 04:05:03 +0000
Subject: [PATCH] feat: download all images of a share link as one ZIP
Twenty pictures meant twenty taps. Now a single button above the grid, a plain
GET link so it works without JavaScript, on a page whose recipients are mostly
on a phone.
Written to stream rather than buffer. The existing /api/items/zip loads every
object into memory and appends them all before the consumer attaches; for a
share of full-resolution posters that is gigabytes at once against a 3g limit.
This route reads one file at a time and waits for archiver's `entry` event
before reading the next. Measured: 440 MB of images, 26 s, RSS peaked at 196 MB
against a 120 MB baseline, and the archive verifies.
Guards, all checked: download-locked links return 403 and the button does not
render; a passphrase-protected link needs the proof cookie (401 without);
revoked 410, unknown 404; five packages per link and address per ten minutes;
200 files and 500 MB, past which the archive closes with a HINWEIS.txt saying
what happened and where to get the rest. Counted as one download.
Name collisions inside the archive are numbered, since duplicates only surface
at extraction time where the second silently overwrites the first. Archive
naming pulled into testable helpers with 7 tests (104 total).
Also fixes a bug the screenshot caught: `.bild a` matched the per-image download
button inside figcaption as well as the image link, painting it paper-coloured
with near-white text. It had been effectively invisible since the page was
written; I had looked at it twice without seeing it. Scoped to `.bild > a`.
Contrast measured from real pixels afterwards: 7.73 on both buttons, 5.08 on the
muted text, all above AA.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
---
src/lib/shares.ts | 33 ++++++++++
src/pages/api/s/[slug]/zip.ts | 115 ++++++++++++++++++++++++++++++++++
src/pages/s/[slug].astro | 22 ++++++-
tests/zipnames.test.ts | Bin 0 -> 2752 bytes
4 files changed, 168 insertions(+), 2 deletions(-)
create mode 100644 src/pages/api/s/[slug]/zip.ts
create mode 100644 tests/zipnames.test.ts
diff --git a/src/lib/shares.ts b/src/lib/shares.ts
index d3e5a5a..cdb30ec 100644
--- a/src/lib/shares.ts
+++ b/src/lib/shares.ts
@@ -303,6 +303,39 @@ export function gleich(a: string, b: string): boolean {
return timingSafeEqual(x, y);
}
+/* ---------------- Namen im Sammelpaket ---------------- */
+
+/**
+ * Eindeutige Dateinamen innerhalb eines ZIP.
+ *
+ * Zwei gleiche Namen im selben Archiv sind kein Fehler beim Packen — sie fallen
+ * erst beim **Auspacken** auf, wo das zweite das erste überschreibt oder der
+ * Entpacker nachfragt. Beides ärgerlich, beides vermeidbar.
+ *
+ * `vergeben` wird dabei fortgeschrieben; die Funktion ist also bewusst nicht rein,
+ * sondern arbeitet auf dem Zustand des laufenden Archivs.
+ */
+export function archivName(
+ roh: string | null | undefined, vergeben: Map, standardEndung = 'jpg',
+): string {
+ let name = String(roh || '').replace(/[/\\]/g, '_').replace(/[\u0000-\u001f]/g, '').trim()
+ || `bild.${standardEndung}`;
+ if (!/\.[a-z0-9]{1,8}$/i.test(name)) name += `.${standardEndung}`;
+ const n = vergeben.get(name);
+ if (n == null) { vergeben.set(name, 0); return name; }
+ vergeben.set(name, n + 1);
+ return name.replace(/(\.[^.]+)$/, `_${n + 1}$1`);
+}
+
+/** Dateiname fürs Sammelpaket: Datum und Titel, auf Dateisystem-Zeichen reduziert. */
+export function paketName(titel: string | null | undefined, am = new Date()): string {
+ const slug = String(titel || '').toLowerCase()
+ .replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
+ .normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'klarbild';
+ return `${am.toISOString().slice(0, 10)}_${slug}.zip`;
+}
+
/* ---------------- Nachweis für ein geprüftes Zugangswort ----------------
* `argon2.verify` kostet rund 130 ms und 64 MiB — und lief vorher bei **jeder**
* Anfrage, also auch für jedes einzelne Bild einer Galerie. Eine Seite mit 40
diff --git a/src/pages/api/s/[slug]/zip.ts b/src/pages/api/s/[slug]/zip.ts
new file mode 100644
index 0000000..7ca471b
--- /dev/null
+++ b/src/pages/api/s/[slug]/zip.ts
@@ -0,0 +1,115 @@
+import type { APIRoute } from 'astro';
+import archiver from 'archiver';
+import { Readable } from 'node:stream';
+import { once } from 'node:events';
+import { query } from '../../../../lib/db';
+import { getObject } from '../../../../lib/storage';
+import { resolveShare, shareItems, trackShare, nachweisGilt, nachweisAusCookie,
+ archivName, paketName } from '../../../../lib/shares';
+import { zuOft, echteIp } from '../../../../lib/limit';
+
+export const prerender = false;
+
+/** Obergrenzen. Ein ZIP ist der teuerste Weg, Daten aus diesem Server zu ziehen. */
+const MAX_DATEIEN = 200;
+const MAX_BYTES = 500 * 1024 * 1024; // 500 MB entpackt
+
+/**
+ * Alle Bilder eines Teilen-Links als ZIP.
+ *
+ * Bewusst **GET**: Damit ist es ein schlichter Link auf der Seite, der auch ohne
+ * JavaScript funktioniert — und der Empfänger ist oft am Handy.
+ *
+ * Anders als `/api/items/zip` (für angemeldete Nutzer) wird hier **eine Datei
+ * nach der anderen** gelesen und weitergereicht: Nach jedem `append` wird auf
+ * das `entry`-Ereignis gewartet. Sonst lägen bei zwanzig Postern schnell ein
+ * paar Gigabyte gleichzeitig im Speicher — und `mem_limit` ist 3 GB.
+ */
+export const GET: APIRoute = async ({ params, request, clientAddress }) => {
+ const slug = params.slug!;
+ let { status, share } = await resolveShare(slug, null);
+ if (status === 'passwort' && share
+ && nachweisGilt(share.id, nachweisAusCookie(request.headers.get('cookie'), slug))) {
+ status = 'ok';
+ }
+ if (status !== 'ok' || !share) {
+ return new Response(
+ status === 'abgelaufen' ? 'Dieser Link ist abgelaufen.'
+ : status === 'widerrufen' ? 'Dieser Link wurde deaktiviert.'
+ : status === 'passwort' ? 'Zugangswort nötig.'
+ : 'Nicht gefunden.',
+ { status: status === 'passwort' ? 401 : status === 'nicht-gefunden' ? 404 : 410 });
+ }
+
+ // Wer den Download gesperrt hat, meint auch das Sammelpaket.
+ if (!share.allow_download) {
+ return new Response('Herunterladen ist für diesen Link ausgeschaltet.', { status: 403 });
+ }
+
+ // Ein ZIP kostet Rechenzeit und Bandbreite — nicht im Sekundentakt.
+ const ip = echteIp(request, clientAddress);
+ if (zuOft(`zip:${share.id}:${ip}`, 5, 10 * 60_000)) {
+ return new Response('Gerade eben schon heruntergeladen. Bitte kurz warten.', { status: 429 });
+ }
+
+ const bilder = await shareItems(share.id);
+ if (!bilder.length) return new Response('Zu diesem Link gibt es keine Bilder.', { status: 404 });
+ if (bilder.length > MAX_DATEIEN) {
+ return new Response(`Dieses Paket wäre zu groß (${bilder.length} Bilder). Bitte einzeln herunterladen.`, { status: 413 });
+ }
+
+ const pfade = await query<{ id: string; result_path: string; filename: string | null }>(
+ `SELECT id, result_path, filename FROM items
+ WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`,
+ [bilder.map((b) => b.id)]);
+ const nachId = new Map(pfade.map((p) => [p.id, p]));
+
+ const archive = archiver('zip', { zlib: { level: 6 } });
+ // Fehler dürfen den Prozess nicht mitnehmen; der Strom bricht dann ab.
+ archive.on('error', (e) => console.error('[share/zip]', e?.message || e));
+ archive.on('warning', (e) => console.error('[share/zip] Warnung:', e?.message || e));
+
+ // Handler stehen, **bevor** etwas hineingeschrieben wird.
+ const web = Readable.toWeb(archive) as ReadableStream;
+
+ (async () => {
+ const vergeben = new Map();
+ let bytes = 0;
+ try {
+ for (const b of bilder) {
+ const p = nachId.get(b.id);
+ if (!p) continue;
+ let buf: Buffer;
+ try { buf = await getObject(p.result_path); }
+ catch (e: any) { console.error('[share/zip] Datei fehlt', p.result_path, e?.message || e); continue; }
+
+ bytes += buf.length;
+ if (bytes > MAX_BYTES) {
+ archive.append(
+ Buffer.from(
+ 'Das Paket wurde bei 500 MB abgeschnitten.\r\n'
+ + 'Die restlichen Bilder lassen sich auf der Seite einzeln herunterladen.\r\n', 'utf8'),
+ { name: 'HINWEIS.txt' });
+ break;
+ }
+
+ archive.append(buf, { name: archivName(p.filename, vergeben) });
+ // Eine Datei nach der anderen: Erst weiterlesen, wenn diese im Strom ist.
+ await once(archive, 'entry');
+ }
+ } finally {
+ archive.finalize().catch(() => {});
+ }
+ })();
+
+ await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
+
+ return new Response(web, {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': `attachment; filename="${paketName(share.title)}"`,
+ 'X-Robots-Tag': 'noindex, nofollow',
+ 'Cache-Control': 'no-store',
+ },
+ });
+};
diff --git a/src/pages/s/[slug].astro b/src/pages/s/[slug].astro
index a8b6991..1685031 100644
--- a/src/pages/s/[slug].astro
+++ b/src/pages/s/[slug].astro
@@ -76,10 +76,20 @@ const meldung: Record = {
+ {share.allow_download && bilder.length > 1 && (
+
+ )}
+
{share.allow_download ? (
Herunterladen speichert die Datei in voller Auflösung.
Am Handy geht es auch so: lange auf ein Bild drücken und speichern.
+ {bilder.length > 1 && <> Oder alle auf einmal, oben.>}
) : (
@@ -151,12 +161,20 @@ const meldung: Record = {
.raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 18px; }
.raster.einzeln { grid-template-columns: minmax(0, 1fr); max-width: 720px; }
.bild { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
- .bild a { display: block; line-height: 0; background: var(--paper); }
+ /* Nur der **direkte** Bildlink, nicht jeder Link in der Figur: `.bild a`
+ traf auch den Herunterladen-Knopf in der Unterzeile und übermalte ihn mit
+ Papierfarbe — heller Text auf hellem Grund, praktisch unsichtbar. */
+ .bild > a { display: block; line-height: 0; background: var(--paper); }
.bild img { width: 100%; height: auto; display: block; }
figcaption { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; }
.mass { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--soft); }
- .knopf { background: var(--accent); color: #fff; text-decoration: none; font-size: 13px; font-weight: 600;
+ .knopf { background: var(--accent); color: var(--card); text-decoration: none; font-size: 13px; font-weight: 600;
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
+ /* Der Sammel-Download steht über den Bildern: Bei zwanzig Kacheln findet
+ ihn unten niemand, und einzeln tippen will man dann erst recht nicht. */
+ .alle { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 0 0 18px; }
+ .knopf.gross { font-size: 15px; padding: 13px 22px; border-radius: var(--radius-sm); }
+ .alle-fein { font-size: 12.5px; color: var(--soft); }
.fein { font-size: 12px; color: var(--soft); }
.leer { color: var(--soft); }
/* Steht über den Bildern: Unter zwanzig Kacheln würde ihn niemand lesen. */
diff --git a/tests/zipnames.test.ts b/tests/zipnames.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..647421bae5d4a73c2724485268a4a9e869c0e4af
GIT binary patch
literal 2752
zcmb_e&2HO95bjwo(Q#3jlpy|CDcss|T)1#+6h;CAHc-G(sw~M7xzTc|E|*qf*FYbl
z_gwl6KKZ6M>63JJN!e86*s4%K0=T;~v-9)Ke9QA`p)_29#!|yIj8&0?BZ~xg&5rZD
z!)pmvmPmrF^Li&Jwc>G0@CulUGk)=k<=BTQo1iwIceZ^$C{-K?9tCB_6qdd&>6%W^
z>vf<9MpgpP;gsb$3ajtK`3yOv0(qR_LPP>`i8-AAd;%qx2}DS0CoGjA6AeV7%Huo5omN{#l@h@-JKzm4cS&J^-U
z`*4U9frubux|SnrB$`x|Dp^sWic@x-KDb?Y
zu@6@rfVhz4XjComoZ-
z2`F-%e2xnRZk_w6z!ISt!)u2?OFz_cFr|dg60+>DAPVBfYljVo(c(K`QCWye+x45S
zt>&VhlIq#2lD)zd$dvM~n8n21kUp2Os1ht)Cvdv3xd}^q!=Xb}eS4-*%1hf9vWBO?
zNbkbeSMvI;wR+u7->Jq5)k1zOIHr`w3>779oosy;eJuLo%SCc5{8&^ktxum-BhLg5
z4Zu|uXKhH>llDFtYT?56IROc1aw$NWR5C3^LG`N<6XB$wHlxco&8brYX|)F4@$0ge1B
zr#KQgBQg<`i+>MzmNygLu!Gz9x#NXfXItTJXnyApKIVoC*@a5}#s_&j7auh7VNWq~B|q|LfHKJ~4s
z{ge96Lq1Ga*526ISj9kVrW{iIwxou$fuN`fjH#xF1XY&c4DZm;j-~bT13eLPx?t`4
zwuna?tklGFq~^Etk7zmWczz=!1oXh*0tR%KWu4u)mwb<3jrY?1?00TOE$E<Aue0=
u(