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 && ( +
+ + Alle {bilder.length} Bilder herunterladen + + als ZIP-Datei, in voller Auflösung +
+ )} + {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