Files
klarbild/src/pages/s/[slug].astro
T
tillandClaude Opus 5 14dae8e550 fix: design critique findings, and the settings card that saved nothing
The critique found a functional bug I had shipped an hour earlier: the whole
e-mail configuration was never sent. `saveSettings` builds its body from a fixed
field list, and none of the smtp_* fields or share_default_days were in it, and
the password input had no state at all. Filling in the card and pressing save
lost everything while the toast said "Gespeichert." That card is the
precondition for password reset, job notifications and mailing share links, so
it would have looked like the whole feature was broken. Now sent, with the
password held separately like the other secrets, and a failed PATCH no longer
reports success.

The public page told recipients how to bypass "view only": the hint about
long-pressing to save was printed regardless of allow_download, two lines under
a caption saying "Nur ansehen". The backend takes that setting seriously enough
to withhold the original even for cutouts; the page undid it. Now the two cases
have separate copy, worded for Android as well as iOS, and the hint sits above
the grid where it is actually read rather than under twenty tiles.

The share dialog had two blue buttons in step two, the exact mistake DESIGN.md
records as having happened once before, and asked five questions before a link
existed when the common case ("one picture for grandma") needs none of them.
One blue button now, and the rarely-used settings collapse behind a summary
that states what currently applies.

/konto was unreachable on a phone: the only way in was the username in the top
bar, which is hidden below 480px. It is a tab bar entry now, with short labels
so eight items still fit at 390px (49x48px each).

Network failures rendered as content: a failed load showed "nothing shared yet"
alongside an invitation to share something, and /konto and /admin hung on
"loading" forever. All three have an error state with a retry now.

Also: the public page nested a second <main> inside the app shell and inherited
padding for a tab bar anonymous visitors never see; admin inputs were 14px, so
iOS zoomed on focus at tablet width; the admin had no media queries at all, so
"Postausgangsserver" got ~156px on a phone; "Jetzt umbenennen" was clickable
without the dry run PRODUCT.md requires; share_default_days did not reach the
dialog; partial mail success was coloured as failure by a regex on the message
text; the passphrase sheet ignored the home indicator; the privacy line claimed
"encrypted" for what is a salted hash.

Added PRODUCT.md and DESIGN.md, written from the code and the wiki rather than
invented, so future design work has something to be measured against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 03:41:10 +00:00

186 lines
9.4 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 ? (
<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.
</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; }
.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;
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
.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>