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
204 lines
10 KiB
Plaintext
204 lines
10 KiB
Plaintext
---
|
||
// Ö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>
|