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', }, }); };