Files
klarbild/src/pages/s/[slug].astro
T
tillandClaude Opus 5 7d53962543 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 04:05:03 +00:00

204 lines
10 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// Öffentliche Ansicht eines Teilen-Links — ohne Anmeldung erreichbar.
//
// Bewusst eine eigene, schlanke Seite statt der App: Wer den Link bekommt, soll
// die Bilder sehen und herunterladen können, sonst nichts. Keine Navigation,
// kein Zugang zur Bibliothek, kein Hinweis darauf, was es sonst noch gibt.
import Base from '../../layouts/Base.astro';
import { resolveShare, shareItems, trackShare, nachweisBauen, nachweisGilt, nachweisAusCookie, nachweisName } from '../../lib/shares';
import { zuOft, echteIp } from '../../lib/limit';
const slug = Astro.params.slug!;
// Zugangswort kommt per POST (nicht als `?pw=` in der Adresse — das landete
// sonst im Verlauf, in Proxy-Protokollen und in jedem weitergeleiteten Link).
// Nach erfolgreicher Prüfung merkt sich ein kurzlebiges, auf diesen Link
// beschränktes Cookie das Ergebnis: `argon2.verify` kostet 130 ms und 64 MiB
// und lief sonst bei **jedem** Bildaufruf erneut.
let eingabe: string | null = null;
let falsch = false;
let gesperrt = false;
if (Astro.request.method === 'POST') {
// Durchprobieren begrenzen: Ein Zugangswort, das man am Telefon durchgibt,
// ist kurz — ohne Bremse wäre Raten aussichtsreich, und jede Prüfung kostet
// 130 ms und 64 MiB.
if (zuOft(`wort:${slug}:${echteIp(Astro.request, Astro.clientAddress)}`, 10, 15 * 60_000)) {
gesperrt = true;
} else {
const f = await Astro.request.formData().catch(() => null);
eingabe = String(f?.get('pw') || '') || null;
}
}
const cookieKopf = Astro.request.headers.get('cookie');
let { status, share } = await resolveShare(slug, eingabe);
// Bereits nachgewiesen? Dann ohne erneutes Hashen durchlassen.
if (status === 'passwort' && share && nachweisGilt(share.id, nachweisAusCookie(cookieKopf, slug))) {
status = 'ok';
}
if (status === 'ok' && share?.password_hash && eingabe) {
Astro.response.headers.append('Set-Cookie',
`${nachweisName(slug)}=${nachweisBauen(share.id)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=21600`);
}
if (status === 'passwort' && eingabe) falsch = true;
let bilder: Awaited<ReturnType<typeof shareItems>> = [];
if (status === 'ok' && share) {
bilder = await shareItems(share.id);
await trackShare(share.id, 'view', Astro.clientAddress, Astro.request.headers.get('user-agent'));
}
// Diese Seite gehört nie in einen Suchindex.
Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, noimageindex');
if (status !== 'ok') Astro.response.status = status === 'nicht-gefunden' ? 404 : status === 'passwort' ? 401 : 410;
const titel = share?.title || (bilder.length > 1 ? 'Bilder' : 'Bild');
const dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1`;
const gross = (id: string) => `/api/s/${slug}/file?item=${id}`;
const klein = (id: string) => `/api/s/${slug}/file?item=${id}&thumb=1`;
const meldung: Record<string, { h: string; t: string }> = {
'nicht-gefunden': { h: 'Diesen Link gibt es nicht', t: 'Vielleicht hat sich beim Kopieren ein Zeichen verloren. Frag am besten noch einmal nach.' },
'abgelaufen': { h: 'Dieser Link ist abgelaufen', t: 'Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.' },
'widerrufen': { h: 'Dieser Link wurde deaktiviert', t: 'Er wurde bewusst abgeschaltet. Frag noch einmal nach, wenn du die Bilder brauchst.' },
};
---
<Base title={`${titel} · Klarbild`}>
<main class="teilen">
{status === 'ok' && share ? (
<>
<header class="kopf">
<p class="marke"><span class="regmark"></span> geteilt über Klarbild</p>
<h1>{titel}</h1>
{share.note && <p class="notiz">{share.note}</p>}
<p class="mono-label">
{bilder.length === 1 ? '1 Bild' : `${bilder.length} Bilder`}
{share.expires_at && <> · gültig bis {new Date(share.expires_at).toLocaleDateString('de-DE')}</>}
</p>
</header>
{share.allow_download && bilder.length > 1 && (
<div class="alle">
<a class="knopf gross" href={`/api/s/${slug}/zip`}>
Alle {bilder.length} Bilder herunterladen
</a>
<span class="alle-fein">als ZIP-Datei, in voller Auflösung</span>
</div>
)}
{share.allow_download ? (
<p class="hinweis">
<b>Herunterladen</b> 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.</>}
</p>
) : (
<p class="hinweis">
Diese Bilder sind zum Ansehen gedacht. Wenn du eine Datei brauchst, frag bei
der Person nach, die dir den Link geschickt hat.
</p>
)}
{bilder.length === 0 ? (
<p class="leer">Hier ist gerade nichts zu sehen. Vielleicht wurden die Bilder inzwischen
entfernt — frag am besten noch einmal bei der Person nach, die dir den Link geschickt hat.</p>
) : (
<div class={bilder.length === 1 ? 'raster einzeln' : 'raster'}>
{bilder.map((b) => (
<figure class="bild">
<a href={gross(b.id)} target="_blank" rel="noopener">
{/* In der Übersicht das Vorschaubild — sonst rechnet der Server
für jede Kachel ein Vollbild neu. Das große Bild kommt erst
beim Antippen. */}
<img src={bilder.length > 1 ? klein(b.id) : gross(b.id)}
alt={b.filename || 'Bild'} loading="lazy" />
</a>
<figcaption>
<span class="mass">{b.output_px?.replace('x', ' × ') || ''}</span>
{share.allow_download
? <a class="knopf" href={dl(b.id)}>Herunterladen</a>
: <span class="fein">Nur ansehen</span>}
</figcaption>
</figure>
))}
</div>
)}
<p class="datenschutz">
Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet
wurde. Nicht, <b>wer</b> das war: Von deiner Internet-Adresse wird nur ein unkenntlicher
Zahlenwert gespeichert, und der wird nach 90 Tagen gelöscht.
</p>
</>
) : status === 'passwort' ? (
<section class="tor">
<h1>Dieser Link ist mit einem Wort geschützt</h1>
<p>Gib das Zugangswort ein, das du zusammen mit dem Link bekommen hast.</p>
<form method="post" class="tor-form">
<input type="password" name="pw" placeholder="Zugangswort" aria-label="Zugangswort" autofocus required />
<button type="submit">Ansehen</button>
</form>
{gesperrt
? <p class="fehler">Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.</p>
: falsch && <p class="fehler">Das war leider nicht richtig.</p>}
</section>
) : (
<section class="tor">
<h1>{meldung[status]?.h ?? 'Nicht verfügbar'}</h1>
<p>{meldung[status]?.t ?? ''}</p>
</section>
)}
</main>
<style>
.teilen { max-width: 1000px; margin: 0 auto; padding: 40px 20px 72px; }
.kopf { margin-bottom: 26px; }
.marke { display: flex; align-items: center; gap: 8px; margin: 0 0 10px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.kopf h1 { font-size: clamp(1.5rem, 4vw, 2rem); margin: 0 0 8px; letter-spacing: -0.02em; }
.notiz { margin: 0 0 10px; color: var(--ink-soft); max-width: 60ch; line-height: 1.6; }
.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; }
/* 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: 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. */
.hinweis { margin: 0 0 20px; padding: 11px 13px; border: 1px solid var(--line);
border-radius: var(--radius-sm); background: var(--card);
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
.datenschutz { margin: 30px 0 0; padding-top: 16px; border-top: 1px solid var(--line);
font-size: 12px; color: var(--soft); line-height: 1.6; max-width: 62ch; }
.tor { max-width: 460px; margin: 8vh auto 0; text-align: center; }
.tor h1 { font-size: 1.4rem; margin: 0 0 12px; }
.tor p { color: var(--ink-soft); line-height: 1.6; margin: 0 0 20px; }
.tor-form { display: flex; gap: 8px; }
.tor-form input { flex: 1; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--card); font-size: 16px; font-family: inherit; color: var(--ink); }
.tor-form button { border: none; border-radius: var(--radius-sm); background: var(--accent); color: #fff;
padding: 12px 20px; font-weight: 600; font-family: inherit; font-size: 15px; cursor: pointer; }
.fehler { color: var(--err); margin-top: 14px; font-size: 14px; }
@media (max-width: 640px) {
.teilen { padding: 24px 14px 56px; }
.raster { grid-template-columns: 1fr; gap: 14px; }
.knopf { padding: 11px 16px; }
}
</style>
</Base>