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
@@ -32,6 +32,11 @@ export const GET: APIRoute = async () => {
|
||||
// NAS-Sicherung
|
||||
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
|
||||
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
|
||||
// E-Mail-Versand (Passwort wird nie zurückgegeben, nur ob es gesetzt ist)
|
||||
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
|
||||
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
|
||||
// Teilen
|
||||
share_default_days: s.share_default_days,
|
||||
} });
|
||||
};
|
||||
|
||||
@@ -43,15 +48,18 @@ export const PATCH: APIRoute = async ({ request }) => {
|
||||
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
|
||||
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
|
||||
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
|
||||
if (b.smtp_password) set('smtp_password', encrypt(String(b.smtp_password)));
|
||||
if (b.generate_api_token) set('api_token', randomToken());
|
||||
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar',
|
||||
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw'];
|
||||
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||
'smtp_secure'];
|
||||
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
|
||||
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
|
||||
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
||||
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
|
||||
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
|
||||
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path',
|
||||
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
|
||||
if (col in b) {
|
||||
const raw = b[col];
|
||||
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { testMail, sendMail, mailLayout, istMail } from '../../../lib/mail';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Ohne `to`: nur die Verbindung prüfen (verschickt nichts).
|
||||
* Mit `to`: eine echte Testnachricht schicken — erst das beweist, dass die
|
||||
* Mail auch ankommt und nicht im Spam landet.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const to = String(b.to || '').trim();
|
||||
if (!to) return json(await testMail());
|
||||
if (!istMail(to)) return json({ ok: false, message: 'Das ist keine gültige Adresse.' }, 400);
|
||||
try {
|
||||
await sendMail({
|
||||
to,
|
||||
subject: 'Klarbild — Testnachricht',
|
||||
text: 'Wenn du das liest, funktioniert der E-Mail-Versand aus Klarbild.\n\n'
|
||||
+ 'Damit können jetzt: Passwort-Rücksetzungen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.',
|
||||
html: mailLayout('Der Versand funktioniert', [
|
||||
'Wenn du das liest, kommen E-Mails aus deiner Klarbild-Installation an.',
|
||||
'Damit funktionieren jetzt: Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.',
|
||||
]),
|
||||
});
|
||||
return json({ ok: true, message: `Testnachricht an ${to} verschickt. Schau auch im Spam-Ordner nach.` });
|
||||
} catch (e: any) {
|
||||
console.error('[test-mail]', e?.message || e);
|
||||
return json({ ok: false, message: 'Versand fehlgeschlagen — Zugangsdaten und Port prüfen.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { hashPassword } from '../../../lib/auth';
|
||||
import { sendMail, mailLayout, mailReady, istMail } from '../../../lib/mail';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const GUELTIG_MINUTEN = 60;
|
||||
const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
|
||||
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
||||
|
||||
// Ein Zähler je Adresse, damit niemand über diese Route Mails verschicken kann.
|
||||
const versuche = new Map<string, { n: number; bis: number }>();
|
||||
function zuOft(schluessel: string, max = 5, fensterMs = 15 * 60_000): boolean {
|
||||
const jetzt = Date.now();
|
||||
const e = versuche.get(schluessel);
|
||||
if (!e || e.bis < jetzt) { versuche.set(schluessel, { n: 1, bis: jetzt + fensterMs }); return false; }
|
||||
e.n++;
|
||||
if (versuche.size > 5000) versuche.clear(); // simpler Schutz gegen Zuwachs
|
||||
return e.n > max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rücksetz-Link anfordern (`{ email }`) oder neues Passwort setzen
|
||||
* (`{ token, password }`).
|
||||
*
|
||||
* Wichtig: Beim Anfordern antwortet die Route **immer gleich**, egal ob es die
|
||||
* Adresse gibt. Sonst ließe sich hier ablesen, wer ein Konto hat.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
|
||||
// --- Neues Passwort setzen -------------------------------------------------
|
||||
if (b.token) {
|
||||
const token = String(b.token);
|
||||
const pw = String(b.password || '');
|
||||
if (pw.length < 10) return json({ error: 'Das Passwort muss mindestens 10 Zeichen haben.' }, 400);
|
||||
if (zuOft('einloesen:' + clientAddress, 20)) return json({ error: 'Zu viele Versuche. Bitte später erneut.' }, 429);
|
||||
|
||||
const r = await one<{ user_id: string }>(
|
||||
`SELECT user_id FROM password_resets
|
||||
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hashToken(token)]);
|
||||
if (!r) return json({ error: 'Dieser Link ist abgelaufen oder wurde schon benutzt.' }, 400);
|
||||
|
||||
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [r.user_id, await hashPassword(pw)]);
|
||||
// Einmalnutzung, und alle anderen offenen Links desselben Kontos verfallen mit.
|
||||
await query('UPDATE password_resets SET used_at = now() WHERE user_id=$1 AND used_at IS NULL', [r.user_id]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// --- Rücksetz-Link anfordern ----------------------------------------------
|
||||
const email = String(b.email || '').trim().toLowerCase();
|
||||
if (!istMail(email)) return json({ error: 'Bitte eine gültige E-Mail-Adresse eingeben.' }, 400);
|
||||
if (zuOft('anfordern:' + clientAddress)) return json({ ok: true }); // still schlucken
|
||||
if (!(await mailReady())) return json({ error: 'Auf diesem Server ist kein E-Mail-Versand eingerichtet.' }, 503);
|
||||
|
||||
const u = await one<{ id: string; display_name: string | null; username: string }>(
|
||||
'SELECT id, display_name, username FROM users WHERE lower(email) = $1', [email]);
|
||||
|
||||
if (u) {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await query(
|
||||
`INSERT INTO password_resets (token_hash, user_id, expires_at)
|
||||
VALUES ($1, $2, now() + ($3 || ' minutes')::interval)`,
|
||||
[hashToken(token), u.id, String(GUELTIG_MINUTEN)]);
|
||||
|
||||
const url = `${basis()}/passwort?token=${token}`;
|
||||
const name = u.display_name || u.username;
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Passwort für Klarbild zurücksetzen',
|
||||
text: `Hallo ${name},\n\nüber den folgenden Link kannst du dir ein neues Passwort für Klarbild setzen. `
|
||||
+ `Der Link gilt ${GUELTIG_MINUTEN} Minuten und lässt sich nur einmal verwenden.\n\n${url}\n\n`
|
||||
+ `Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
|
||||
html: mailLayout('Neues Passwort setzen', [
|
||||
`Hallo ${name},`,
|
||||
`über den Knopf unten setzt du dir ein neues Passwort für Klarbild. Der Link gilt <b>${GUELTIG_MINUTEN} Minuten</b> und lässt sich nur <b>einmal</b> verwenden.`,
|
||||
`Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
|
||||
], { text: 'Neues Passwort setzen', url }),
|
||||
}).catch((e) => console.error('[reset] Mail fehlgeschlagen:', e?.message || e));
|
||||
}
|
||||
|
||||
// Immer dieselbe Antwort — sonst verrät sie, welche Adressen ein Konto haben.
|
||||
return json({ ok: true });
|
||||
};
|
||||
+38
-4
@@ -1,15 +1,49 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one } from '../../lib/db';
|
||||
import { one, query } from '../../lib/db';
|
||||
import { istMail, mailReady } from '../../lib/mail';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
|
||||
const u = await one<{ private_forced: boolean }>('SELECT private_forced FROM users WHERE id=$1', [locals.user.uid]);
|
||||
return new Response(JSON.stringify({
|
||||
const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
|
||||
'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
|
||||
return json({
|
||||
role: locals.user.role,
|
||||
private_allowed: !!s?.private_allowed,
|
||||
private_forced: !!u?.private_forced,
|
||||
}), { headers: { 'Content-Type': 'application/json' } });
|
||||
// Eigenes Konto: E-Mail und Benachrichtigungen
|
||||
username: u?.username ?? null,
|
||||
display_name: u?.display_name ?? null,
|
||||
email: u?.email ?? null,
|
||||
notify_jobs: !!u?.notify_jobs,
|
||||
mail_ready: await mailReady(),
|
||||
});
|
||||
};
|
||||
|
||||
/** Eigenes Konto pflegen: E-Mail hinterlegen und Benachrichtigungen schalten. */
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user?.uid) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({}));
|
||||
|
||||
if ('email' in b) {
|
||||
const roh = String(b.email ?? '').trim().toLowerCase();
|
||||
if (roh && !istMail(roh)) return json({ error: 'Das sieht nicht nach einer gültigen Adresse aus.' }, 400);
|
||||
// Die Adresse ist der Weg, ein Passwort zurückzusetzen — sie darf deshalb
|
||||
// nicht zweimal vergeben sein.
|
||||
if (roh) {
|
||||
const belegt = await one<{ id: string }>(
|
||||
'SELECT id FROM users WHERE lower(email)=$1 AND id<>$2', [roh, locals.user.uid]);
|
||||
if (belegt) return json({ error: 'Diese Adresse gehört schon zu einem anderen Konto.' }, 409);
|
||||
}
|
||||
await query('UPDATE users SET email=$2 WHERE id=$1', [locals.user.uid, roh || null]);
|
||||
}
|
||||
|
||||
if ('notify_jobs' in b) {
|
||||
await query('UPDATE users SET notify_jobs=$2 WHERE id=$1', [locals.user.uid, !!b.notify_jobs]);
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import sharp from 'sharp';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
import { resolveShare, shareHasItem, trackShare } from '../../../../lib/shares';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/**
|
||||
* Bild hinter einem Teilen-Link ausliefern — **ohne Anmeldung**.
|
||||
*
|
||||
* Die Zugriffsprüfung hängt allein am Link, nicht am Bild: Nur was in
|
||||
* `share_items` steht, kommt heraus. Damit kann niemand über einen gültigen Link
|
||||
* fremde Bilder abrufen, indem er eine andere Bild-Kennung einsetzt.
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
|
||||
const q = url.searchParams;
|
||||
const { status, share } = await resolveShare(params.slug!, q.get('pw'));
|
||||
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 });
|
||||
}
|
||||
|
||||
const itemId = q.get('item');
|
||||
if (!itemId || !/^[0-9a-f-]{36}$/i.test(itemId)) return new Response('Nicht gefunden.', { status: 404 });
|
||||
if (!(await shareHasItem(share.id, itemId))) return new Response('Nicht gefunden.', { status: 404 });
|
||||
|
||||
const item = await one<any>(
|
||||
'SELECT result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1', [itemId]);
|
||||
if (!item?.result_path) return new Response('Nicht gefunden.', { status: 404 });
|
||||
|
||||
const download = q.get('download') === '1';
|
||||
const wantThumb = q.get('thumb') === '1';
|
||||
// Wer den Download abgeschaltet hat, gibt auch die Originaldatei nicht heraus —
|
||||
// sonst wäre die Einstellung nur Zierde.
|
||||
if (download && !share.allow_download) return new Response('Herunterladen ist für diesen Link aus.', { status: 403 });
|
||||
|
||||
const path = wantThumb ? (item.thumb_path || item.result_path) : item.result_path;
|
||||
try {
|
||||
let buf = await getObject(path);
|
||||
const sniff = (b: Buffer): string =>
|
||||
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
|
||||
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
|
||||
: 'image/png';
|
||||
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
|
||||
|
||||
// Ansichtsfassung: kleiner und schneller, vor allem am Handy. Der Download
|
||||
// liefert weiterhin die unveränderte Originaldatei in voller Auflösung.
|
||||
if (!download && !wantThumb && !item.has_alpha) {
|
||||
try {
|
||||
buf = await sharp(buf, { failOn: 'none' })
|
||||
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 88, mozjpeg: true }).toBuffer();
|
||||
contentType = 'image/jpeg';
|
||||
} catch { /* dann eben das Original */ }
|
||||
}
|
||||
|
||||
if (download) {
|
||||
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(buf.length),
|
||||
// Nicht in Suchmaschinen und nicht in fremde Zwischenspeicher.
|
||||
'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
};
|
||||
if (download) {
|
||||
const name = (item.filename || 'klarbild.jpg').replace(/[^\w.\-]/g, '_');
|
||||
headers['Content-Disposition'] = `attachment; filename="${name}"`;
|
||||
}
|
||||
return new Response(new Uint8Array(buf), { headers });
|
||||
} catch {
|
||||
return new Response('Bild nicht mehr vorhanden.', { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { updateShare, revokeShare, unrevokeShare, deleteShare } from '../../../lib/shares';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Die letzten Zugriffe auf einen Link — für die Auswertung im Frontend. */
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
|
||||
const istAdmin = locals.user.role === 'admin';
|
||||
const rows = await query<any>(
|
||||
`SELECT v.kind, v.at, v.user_agent
|
||||
FROM share_views v JOIN shares s ON s.id = v.share_id
|
||||
WHERE v.share_id = $1 AND ($3::boolean OR s.created_by = $2)
|
||||
ORDER BY v.at DESC LIMIT 100`, [params.id, locals.user.uid, istAdmin]);
|
||||
return json({ zugriffe: rows });
|
||||
};
|
||||
|
||||
/** Titel, Ablauf, Zugangswort oder Download-Erlaubnis ändern; widerrufen/freigeben. */
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const istAdmin = locals.user.role === 'admin';
|
||||
|
||||
if (b.action === 'revoke') {
|
||||
return json({ ok: await revokeShare(params.id!, locals.user.uid, istAdmin) });
|
||||
}
|
||||
if (b.action === 'unrevoke') {
|
||||
return json({ ok: await unrevokeShare(params.id!, locals.user.uid, istAdmin) });
|
||||
}
|
||||
const ok = await updateShare(params.id!, locals.user.uid, istAdmin, {
|
||||
title: b.title, note: b.note, days: b.days,
|
||||
allowDownload: b.allowDownload, password: b.password,
|
||||
});
|
||||
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Link.' }, 404);
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
|
||||
const ok = await deleteShare(params.id!, locals.user.uid, locals.user.role === 'admin');
|
||||
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Link.' }, 404);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one } from '../../../lib/db';
|
||||
import { createShare, listShares, MAX_ITEMS } from '../../../lib/shares';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Eigene Teilen-Links auflisten (Admins sehen alle). */
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const shares = await listShares(locals.user.uid, locals.user.role === 'admin');
|
||||
return json({ shares });
|
||||
};
|
||||
|
||||
/**
|
||||
* Neuen Link anlegen.
|
||||
* Es dürfen nur Bilder geteilt werden, die man auch sehen darf — sonst wäre der
|
||||
* Teilen-Knopf ein bequemer Weg an der Sichtbarkeitsregel vorbei.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const ids: string[] = Array.isArray(b.itemIds) ? b.itemIds.filter((x: any) => typeof x === 'string') : [];
|
||||
if (!ids.length) return json({ error: 'Keine Bilder ausgewählt.' }, 400);
|
||||
if (ids.length > MAX_ITEMS) return json({ error: `Höchstens ${MAX_ITEMS} Bilder je Link.` }, 422);
|
||||
|
||||
const istAdmin = locals.user.role === 'admin';
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
for (const id of ids) {
|
||||
const it = await one<any>(
|
||||
`SELECT i.id, i.status, i.result_path, j.created_by, j.private
|
||||
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [id]);
|
||||
if (!it?.result_path || it.status !== 'done') return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
|
||||
const eigen = it.created_by === locals.user.uid;
|
||||
if (!istAdmin && !eigen && (it.private || s?.library_visibility !== 'shared'))
|
||||
return json({ error: 'Kein Zugriff auf eines der Bilder.' }, 403);
|
||||
// Private Aufträge werden bewusst nie geteilt — sie sollen ja gerade
|
||||
// keine Spur hinterlassen.
|
||||
if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const share = await createShare({
|
||||
itemIds: ids,
|
||||
kind: b.kind === 'collection' ? 'collection' : undefined,
|
||||
title: b.title, note: b.note,
|
||||
days: b.days, allowDownload: b.allowDownload, password: b.password,
|
||||
userId: locals.user.uid,
|
||||
});
|
||||
return json({ share });
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Link konnte nicht angelegt werden.' }, 400);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one } from '../../../lib/db';
|
||||
import { sendMail, mailLayout, mailReady, istMail } from '../../../lib/mail';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
||||
const MAX_EMPFAENGER = 10;
|
||||
|
||||
/**
|
||||
* Einen Teilen-Link per E-Mail verschicken.
|
||||
*
|
||||
* Bewusst **ohne Bildanhang**: Ein Poster in voller Auflösung sprengt jedes
|
||||
* Postfach, und ein Anhang lässt sich später nicht mehr zurückziehen. Der Link
|
||||
* dagegen schon — er hat ein Ablaufdatum und kann jederzeit deaktiviert werden.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({}));
|
||||
|
||||
const empfaenger: string[] = (Array.isArray(b.to) ? b.to : String(b.to || '').split(/[,;\s]+/))
|
||||
.map((x: string) => String(x).trim().toLowerCase()).filter(Boolean);
|
||||
if (!empfaenger.length) return json({ error: 'Keine Empfängeradresse angegeben.' }, 400);
|
||||
if (empfaenger.length > MAX_EMPFAENGER) return json({ error: `Höchstens ${MAX_EMPFAENGER} Empfänger auf einmal.` }, 422);
|
||||
const schlecht = empfaenger.filter((e) => !istMail(e));
|
||||
if (schlecht.length) return json({ error: `Keine gültige Adresse: ${schlecht[0]}` }, 400);
|
||||
|
||||
if (!(await mailReady())) return json({ error: 'Es ist kein E-Mail-Versand eingerichtet (Admin → E-Mail).' }, 503);
|
||||
|
||||
const istAdmin = locals.user.role === 'admin';
|
||||
const share = await one<any>(
|
||||
`SELECT s.*, (SELECT count(*)::int FROM share_items si WHERE si.share_id = s.id) AS anzahl
|
||||
FROM shares s WHERE s.id = $1 AND ($3::boolean OR s.created_by = $2)`,
|
||||
[b.shareId, locals.user.uid, istAdmin]);
|
||||
if (!share) return json({ error: 'Unbekannter Link.' }, 404);
|
||||
if (share.revoked_at) return json({ error: 'Dieser Link ist deaktiviert — erst wieder freigeben.' }, 409);
|
||||
|
||||
const url = `${basis()}/s/${share.slug}`;
|
||||
const absender = locals.user.name || locals.user.username || 'Jemand';
|
||||
const titel = share.title || (share.anzahl > 1 ? `${share.anzahl} Bilder` : 'Ein Bild');
|
||||
const nachricht = String(b.message || '').trim().slice(0, 1000);
|
||||
const gueltig = share.expires_at
|
||||
? `Der Link gilt bis zum ${new Date(share.expires_at).toLocaleDateString('de-DE')}.`
|
||||
: 'Der Link gilt unbegrenzt.';
|
||||
const wort = share.password_hash
|
||||
? 'Zum Öffnen brauchst du außerdem das Zugangswort — das bekommst du getrennt.'
|
||||
: '';
|
||||
|
||||
const absaetze = [
|
||||
`${absender} hat dir etwas über Klarbild geschickt: <b>${titel}</b>.`,
|
||||
...(nachricht ? [nachricht.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '<br>')] : []),
|
||||
[gueltig, wort].filter(Boolean).join(' '),
|
||||
];
|
||||
|
||||
let ok = 0; const fehler: string[] = [];
|
||||
for (const to of empfaenger) {
|
||||
try {
|
||||
await sendMail({
|
||||
to,
|
||||
subject: `${absender} teilt: ${titel}`,
|
||||
text: `${absender} hat dir etwas über Klarbild geschickt: ${titel}.\n\n`
|
||||
+ (nachricht ? `${nachricht}\n\n` : '')
|
||||
+ `${url}\n\n${gueltig}${wort ? ' ' + wort : ''}`,
|
||||
html: mailLayout(titel, absaetze, { text: 'Bilder ansehen', url }),
|
||||
});
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
console.error('[share/send] an', to, 'fehlgeschlagen:', e?.message || e);
|
||||
fehler.push(to);
|
||||
}
|
||||
}
|
||||
return json({ ok, fehler, url });
|
||||
};
|
||||
Reference in new issue
Block a user