feat: e-mail, password reset and shareable links for images and collections
Mail goes out through the account's own mailbox (SMTP) rather than a sending service: the domain lives there anyway, so SPF and DKIM line up by themselves and no third party gets to see the messages. Credentials are AES-256-GCM encrypted in settings like every other secret, never in the repo. nodemailer has zero dependencies and no native postinstall -- checked before adding it, given the history. Password reset: single-use token, only its hash is stored, one hour, and the request endpoint answers identically whether or not the address exists so it cannot be used to enumerate accounts. Rate limited per address. Share links (/s/<slug>) let someone without an account see the pictures and download them at full resolution. These are photos of children, so the defaults lean safe: every link expires by itself (30 days), can be revoked, optionally carries a passphrase, and the page and files are noindex. Access is bound to the link, not the image -- swapping in another item id on a valid link returns 404. Private-session results can never be shared at all. Tracking records when and with what, never who: IP addresses are only stored salted-and-hashed, so repeat visitors can be counted but nobody identified. Sharing goes out by mail (several recipients at once, no attachment -- a link can be withdrawn later, an attachment cannot) or via wa.me, which opens WhatsApp with the text prepared. The WhatsApp Business API would need a Meta account, approved templates and per-conversation fees; this works today for free and the API can slot in behind it later. Verified end to end against a local SMTP server: reset issues and consumes its token exactly once, the old password stops working, an unknown address gives the same answer, share mail reaches several recipients. Link behaviour checked for expiry (410), revocation (410), wrong passphrase (401) and download lock (403). Outbound SMTP is blocked in the sandbox, so the real mailbox still needs one test from the live server. Also fixes a regression this introduced: the seventh nav item pushed the logout button off screen at 820px. The bar now shrinks and scrolls, logout never does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
27 files changed
+1946
-13
No files matched your search
@@ -0,0 +1,125 @@
|
||||
---
|
||||
// Ö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 } from '../../lib/shares';
|
||||
|
||||
const slug = Astro.params.slug!;
|
||||
const pw = Astro.url.searchParams.get('pw');
|
||||
const { status, share } = await resolveShare(slug, pw);
|
||||
|
||||
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${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`;
|
||||
const gross = (id: string) => `/api/s/${slug}/file?item=${id}${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`;
|
||||
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">
|
||||
<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>
|
||||
|
||||
{bilder.length === 0 ? (
|
||||
<p class="leer">Zu diesem Link gibt es gerade keine Bilder.</p>
|
||||
) : (
|
||||
<div class={bilder.length === 1 ? 'raster einzeln' : 'raster'}>
|
||||
{bilder.map((b) => (
|
||||
<figure class="bild">
|
||||
<a href={gross(b.id)} target="_blank" rel="noopener">
|
||||
<img src={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="hinweis">
|
||||
Am Handy: Bild antippen, dann lange darauf drücken und „Zu Fotos hinzufügen" wählen.
|
||||
{share.allow_download && <> Über <b>Herunterladen</b> kommt die Datei in voller Auflösung.</>}
|
||||
</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="get" class="tor-form">
|
||||
<input type="password" name="pw" placeholder="Zugangswort" aria-label="Zugangswort" autofocus required />
|
||||
<button type="submit">Ansehen</button>
|
||||
</form>
|
||||
{pw && <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; }
|
||||
.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); }
|
||||
.hinweis { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--line);
|
||||
font-size: 13px; color: var(--ink-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>
|
||||
Reference in new issue
Block a user