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 0000000..647421b
Binary files /dev/null and b/tests/zipnames.test.ts differ