diff --git a/CHANGELOG.md b/CHANGELOG.md index bced11c..6dd2014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to Klarbild are documented here. Newest first. +## 2026-10-10 — Trash, access rules, current dependencies + +### Added +- **Trash instead of deleting for good.** Deleting moves an image into the trash + (`items.deleted_at`, migration 017). It disappears from the library, share links, ZIPs, + deliveries, the backup mirror, print sources and the print history, and returns + unchanged on restore. A toast offers "Rückgängig"; the trash view lists days left, + restore, delete for good, empty. Maintenance purges after `settings.trash_days` + (default 30, Admin → Speicher); 0 switches the trash off. API: `DELETE /api/items/:id` + (trash), `?purge=1`, `GET|POST|DELETE /api/trash`. +- Purging deletes only files no other image still uses — alternatives share their source + with the original, including combine sources in `source_paths` (jsonb). +- An image trashed while queued is skipped by the worker instead of being generated. +- Favicon and iPhone home-screen icon. + +### Fixed +- **Access checks on every image route.** Only the file route checked visibility before; + rename, delete, retry, alternative, reuse and the selection ZIP accepted any id from any + account. One rule in `src/lib/itemaccess.ts`: read own (or shared library, never a + private session), change own; admins all. +- Telegram print sheets had no footer: the footer was passed as a string instead of a + `FooterSpec`, so `.text` was undefined. +- `npm run typecheck` exists and reports 0 errors (17 before). + +### Changed +- Dependencies: nodemailer 10, basic-ftp 6, argon2 0.45, archiver 8 (`ZipArchive`), + ssh2-sftp-client 12, @astrojs/react 7, Astro 7.3.8, sharp 0.35.5. `npm audit`: 0. + pg-boss stays on 10 (12 cannot migrate the v10 schema in place). +- basic-ftp 6 no longer redirects the data connection when an FTP server behind NAT + announces its private address. Run "Verbindung testen" for each FTPS target. + ## 2026-09-01 (later) — Unique names for print files ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d7d0511..9c57269 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Arbeitsanweisung für den bauenden Agenten. Führend: `01-anforderungen.md` (Was `Imagetool-Lea/` (00,01,03,06,07; 02/04/05 stehen noch aus). ## Stack (fest) -Astro 5 (`@astrojs/node`, `output: server`) + React-Inseln · Postgres · S3 (MinIO) · +Astro 7 (`@astrojs/node` 11, `output: server`) + React-Inseln · Postgres · S3 (MinIO) · pg-boss (Worker im selben Prozess, `worker.ts`) · sharp · grammY · argon2 · AES-256-GCM. Kein Tailwind. OKLCH-Tokens in `src/styles/tokens.css`. Gitea → Coolify → Hetzner. diff --git a/migrations/017_papierkorb.sql b/migrations/017_papierkorb.sql new file mode 100644 index 0000000..8ff3d44 --- /dev/null +++ b/migrations/017_papierkorb.sql @@ -0,0 +1,13 @@ +-- Papierkorb statt endgültigem Löschen --------------------------------------- +-- +-- Löschen in der Bibliothek hat bisher Zeile und Ergebnisdatei sofort entfernt. +-- Ein Fehlklick, und das Ergebnis war weg — samt dem, was die Erzeugung gekostet +-- hat. Jetzt setzt Löschen nur `deleted_at`. Das Bild verschwindet aus jeder +-- Ansicht, jedem Freigabelink und jeder Auslieferung und kommt beim +-- Wiederherstellen unverändert zurück. Die Wartung entfernt es nach +-- `settings.trash_days` endgültig. +ALTER TABLE items ADD COLUMN IF NOT EXISTS deleted_at timestamptz; +CREATE INDEX IF NOT EXISTS items_deleted_at_idx ON items (deleted_at) WHERE deleted_at IS NOT NULL; + +-- 0 = kein Papierkorb: Löschen ist sofort endgültig, wie früher. +ALTER TABLE settings ADD COLUMN IF NOT EXISTS trash_days int NOT NULL DEFAULT 30; diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..aa98074 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..c2e4c42 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 23ef501..0367369 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -82,6 +82,7 @@ export default function AdminApp() { output_ext: s.output_ext || 'png', cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days, + trash_days: s.trash_days ?? 30, picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar, library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations, private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, @@ -317,6 +318,9 @@ export default function AdminApp() {
field('retention_days', e.target.value)} />
Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.
+
+ field('trash_days', e.target.value)} /> +
So lange bleiben gelöschte Bilder im Papierkorb und lassen sich bis dahin wiederherstellen. 0 = kein Papierkorb, Löschen ist endgültig.
diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index 41eb799..e2e0e03 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -28,8 +28,16 @@ export default function LibraryApp() { const [tagMenu, setTagMenu] = useState(null); const [teilen, setTeilen] = useState(null); const [tagFilter, setTagFilter] = useState(null); - const [toast, setToast] = useState(null); - const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; + const [toast, setToast] = useState<{ msg: string; undo?: () => void } | null>(null); + const toastTimer = React.useRef | null>(null); + // Mit Rückgängig-Knopf bleibt der Hinweis länger stehen — lang genug, um den Fehlklick zu bemerken. + const notify = (msg: string, undo?: () => void) => { + if (toastTimer.current) clearTimeout(toastTimer.current); + setToast({ msg, undo }); + toastTimer.current = setTimeout(() => setToast(null), undo ? 7000 : 2400); + }; + const [korbAnsicht, setKorbAnsicht] = useState(false); + const [korb, setKorb] = useState<{ days: number; items: (Item & { deleted_at: string; days_left: number })[] }>({ days: 30, items: [] }); // Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID. const modelName = (id: string | null) => { @@ -40,12 +48,14 @@ export default function LibraryApp() { }; const load = useCallback(async () => { - const [i, f, m] = await Promise.all([ + const [i, f, m, k] = await Promise.all([ fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })), fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })), fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })), + fetch('/api/trash').then((r) => r.json()).catch(() => ({ days: 30, items: [] })), ]); setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []); + setKorb({ days: k.days ?? 30, items: k.items || [] }); }, []); useEffect(() => { load(); }, [load]); @@ -60,8 +70,30 @@ export default function LibraryApp() { await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) }); load(); }; - const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); }; - const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); }; + const korbAktion = (action: 'trash' | 'restore' | 'purge', ids: string[]) => + fetch('/api/trash', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ids }) }) + .then((r) => r.json()).catch(() => ({ ids: [] })); + // Löschen legt in den Papierkorb; der Hinweis bietet direkt an, es zurückzunehmen. + const del = async (ids: string[]) => { + if (!ids.length) return; + setItems((x) => x.filter((y) => !ids.includes(y.id))); + const r = await korbAktion('trash', ids); + setPick(new Set()); load(); + const n = (r.ids || []).length; + if (korb.days === 0) { notify(n === 1 ? 'Gelöscht.' : `${n} gelöscht.`); return; } + notify(n === 1 ? 'In den Papierkorb gelegt.' : `${n} in den Papierkorb gelegt.`, + async () => { await korbAktion('restore', r.ids || []); setToast(null); load(); }); + }; + const delSelected = () => del([...pick]); + const wiederherstellen = async (ids: string[]) => { await korbAktion('restore', ids); load(); notify('Wiederhergestellt.'); }; + const endgueltig = async (ids: string[]) => { + if (!confirm(ids.length === 1 ? 'Dieses Bild endgültig löschen? Das lässt sich nicht rückgängig machen.' : `${ids.length} Bilder endgültig löschen? Das lässt sich nicht rückgängig machen.`)) return; + await korbAktion('purge', ids); load(); notify('Endgültig gelöscht.'); + }; + const korbLeeren = async () => { + if (!confirm(`Papierkorb leeren? ${korb.items.length} Bilder werden endgültig gelöscht.`)) return; + await fetch('/api/trash', { method: 'DELETE' }); load(); notify('Papierkorb geleert.'); + }; const deliverSelected = async () => { notify('Sende an Picdrop …'); const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json()); @@ -129,9 +161,9 @@ export default function LibraryApp() { return (
- Bibliothek{filtered.length ? ` · ${filtered.length}` : ''} + {korbAnsicht ? `Papierkorb · ${korb.items.length}` : <>Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}}
- {pick.size > 0 && <> + {pick.size > 0 && !korbAnsicht && <> {pick.size} gewählt @@ -143,10 +175,43 @@ export default function LibraryApp() { } - + {!korbAnsicht && } + {(korb.items.length > 0 || korbAnsicht) && ( + + )}
+ {korbAnsicht ? ( +
+
{korb.days > 0 + ? `Gelöschte Bilder bleiben ${korb.days} Tage hier und lassen sich bis dahin wiederherstellen. In der Bibliothek, in Freigabelinks und bei der Auslieferung tauchen sie nicht mehr auf.` + : 'Der Papierkorb ist abgeschaltet — Löschen ist endgültig.'}
+ {korb.items.length === 0 ? ( +

Der Papierkorb ist leer

+ ) : (<> +
+ + +
+
+ {korb.items.map((v) => ( +
+
+
{v.filename || '—'}
+
{v.days_left === 0 ? 'Wird beim nächsten Aufräumen entfernt' : v.days_left === 1 ? 'Noch 1 Tag' : `Noch ${v.days_left} Tage`}
+
+ + +
+
+ ))} +
+ )} +
+ ) : (<> + {/* Ordner-Filter */}
@@ -236,7 +301,7 @@ export default function LibraryApp() { {folders.map((o) => )} - +
); @@ -244,6 +309,8 @@ export default function LibraryApp() {
)} + )} + {teilen && ( setTeilen(null)} /> )} @@ -282,7 +349,8 @@ export default function LibraryApp() {
)} - {toast &&
{toast}
} + {toast &&
{toast.msg} + {toast.undo && }
} ); @@ -355,6 +423,10 @@ function LibStyles() { .promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;} .pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;} .pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;} - .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;} + .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;display:flex;align-items:center;gap:14px;} + .toast-undo{background:none;border:1px solid rgba(251,251,247,.5);color:#FBFBF7;border-radius:3px;padding:4px 10px;font-family:inherit;font-size:12.5px;cursor:pointer;} + .mini.gefahr{border-color:var(--err);color:var(--err);} + .kachel.geloescht .kachel-bild img{opacity:.6;filter:grayscale(.6);} + .name.fest{font-family:var(--font-mono);font-size:12px;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} `}; } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index c09e060..f2daad2 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -42,6 +42,8 @@ const ICONS: Record = { + + {title} diff --git a/src/lib/backup.ts b/src/lib/backup.ts index 494409c..1e1ebca 100644 --- a/src/lib/backup.ts +++ b/src/lib/backup.ts @@ -63,7 +63,7 @@ export async function backupAll(): Promise<{ items: number; mirrored: number; fa const dests = await backupDestinations(); if (!dests.length) return { items: 0, mirrored: 0, failed: 0 }; const rows = await query<{ id: string }>( - `SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`); + `SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL AND deleted_at IS NULL ORDER BY created_at`); let mirrored = 0, failed = 0; for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; } return { items: rows.length, mirrored, failed }; diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index 086ca84..13bf995 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -80,6 +80,6 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag /** Alle noch offenen (pending) Auslieferungen eines Auftrags abarbeiten. */ export async function deliverPendingForJob(jobId: string): Promise { const items = await query<{ id: string }>( - `SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done'`, [jobId]); + `SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done' AND deleted_at IS NULL`, [jobId]); for (const it of items) await deliverItem(it.id); } diff --git a/src/lib/itemaccess.ts b/src/lib/itemaccess.ts new file mode 100644 index 0000000..72e71ff --- /dev/null +++ b/src/lib/itemaccess.ts @@ -0,0 +1,49 @@ +// Wer darf was mit einem Bild? +// +// Eine Regel an einer Stelle. Vorher prüfte nur die Datei-Route den Zugriff; +// Umbenennen, Löschen, Wiederholen und ZIP nahmen jede Kennung von jedem +// angemeldeten Konto. Bei einem Konto ändert das nichts. Bei Till und Lea hieß +// es: Ein Konto konnte per Kennung die Bilder des anderen löschen. +import { one } from './db.ts'; + +export interface Viewer { uid: string | null; role: string } + +export interface ItemAccessRow { + id: string; + created_by: string | null; + private: boolean | null; + deleted_at: string | null; +} + +/** Lesen: eigene Bilder, als Admin alle, fremde nur bei geteilter Bibliothek und nie aus einer privaten Sitzung. */ +export function canRead(v: Viewer, it: Pick, libraryShared: boolean): boolean { + if (v.role === 'admin') return true; + if (v.uid && it.created_by === v.uid) return true; + return libraryShared && !it.private; +} + +/** Ändern oder Löschen: nur eigene Bilder, oder als Admin. Eine geteilte Bibliothek ist zum Ansehen da, nicht zum Aufräumen fremder Arbeit. */ +export function canWrite(v: Viewer, it: Pick): boolean { + if (v.role === 'admin') return true; + return !!v.uid && it.created_by === v.uid; +} + +/** + * Lädt ein Bild samt dem, was die Zugriffsentscheidung braucht. + * Liefert null für „gibt es nicht" und für „nicht deins" — durch Ausprobieren von + * Kennungen soll sich das nicht unterscheiden lassen. + */ +export async function loadItemFor( + v: Viewer, id: string, mode: 'read' | 'write', opts: { includeTrashed?: boolean } = {}, +): Promise { + if (!/^[0-9a-f-]{36}$/i.test(id)) return null; + const it = await one( + `SELECT i.id, j.created_by, j.private, i.deleted_at, + (SELECT library_visibility FROM settings WHERE id=1) AS library_visibility + FROM items i LEFT JOIN jobs j ON j.id = i.job_id + WHERE i.id = $1`, [id]); + if (!it) return null; + if (it.deleted_at && !opts.includeTrashed) return null; + const ok = mode === 'write' ? canWrite(v, it) : canRead(v, it, it.library_visibility === 'shared'); + return ok ? it : null; +} diff --git a/src/lib/maintenance.ts b/src/lib/maintenance.ts index 1c5ba20..ca8f9c6 100644 --- a/src/lib/maintenance.ts +++ b/src/lib/maintenance.ts @@ -138,6 +138,9 @@ export function startMaintenance(): void { const r = await runRetention(s.retention_days); if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`); } + const { purgeExpiredTrash } = await import('./trash'); + const tr = await purgeExpiredTrash(); + if (tr) console.log(`[maintenance] Papierkorb: ${tr} Bilder endgültig entfernt.`); const p = await purgePrivate(60); if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`); const t = await purgeTracking(90); diff --git a/src/lib/printruns.ts b/src/lib/printruns.ts index ba717a0..4b188b3 100644 --- a/src/lib/printruns.ts +++ b/src/lib/printruns.ts @@ -99,7 +99,7 @@ export async function recordRun(e: RunEingang): Promise { await query( `INSERT INTO print_run_items (run_id, item_id) SELECT $1, x FROM unnest($2::uuid[]) AS x - WHERE EXISTS (SELECT 1 FROM items WHERE id = x) + WHERE EXISTS (SELECT 1 FROM items WHERE id = x AND deleted_at IS NULL) ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) => console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err)); } @@ -142,7 +142,7 @@ export async function listRuns(userId: string | null, istAdmin: boolean, limit = const bilder = await query<{ run_id: string; id: string; filename: string | null }>( `SELECT ri.run_id, i.id, i.filename FROM print_run_items ri JOIN items i ON i.id = ri.item_id - WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`, + WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL`, [runs.map((r) => r.id)]); const nachRun = new Map(); diff --git a/src/lib/printsource.ts b/src/lib/printsource.ts index 846943d..8ac7413 100644 --- a/src/lib/printsource.ts +++ b/src/lib/printsource.ts @@ -37,7 +37,7 @@ export async function loadSourceNamed(src: PrintSource, user: SessionUser): Prom if (src.kind === 'item') { const item = await one( `SELECT i.result_path, i.filename, j.created_by, j.private - FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]); + FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1 AND i.deleted_at IS NULL`, [src.id]); if (!item?.result_path) throw new Error('Bild nicht gefunden.'); const isAdmin = user.role === 'admin'; const own = item.created_by === user.uid; diff --git a/src/lib/process.ts b/src/lib/process.ts index dff0f69..c4528e9 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -61,9 +61,15 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; } /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ export async function processItem(itemId: string): Promise { - const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>( - 'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]); + const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null; deleted_at: string | null }>( + 'SELECT id, job_id, source_path, source_paths, filename, deleted_at FROM items WHERE id=$1', [itemId]); if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' }; + // Noch in der Warteschlange in den Papierkorb gelegt: kein Modell für ein Bild + // bezahlen, das niemand mehr will. + if (item.deleted_at) { + await query(`UPDATE items SET status='skipped' WHERE id=$1 AND status IN ('queued','running')`, [itemId]); + return { ok: true, cost: 0 }; + } const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>( 'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]); diff --git a/src/lib/shares.ts b/src/lib/shares.ts index cdb30ec..8bb1ee3 100644 --- a/src/lib/shares.ts +++ b/src/lib/shares.ts @@ -155,14 +155,16 @@ export async function shareItems(shareId: string): Promise { `SELECT i.id, i.filename, i.output_px, i.created_at FROM share_items si JOIN items i ON i.id = si.item_id - WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL + WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL ORDER BY si.position, i.created_at`, [shareId]); } /** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */ export async function shareHasItem(shareId: string, itemId: string): Promise { const r = await one<{ item_id: string }>( - 'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]); + // Ein Bild im Papierkorb gehört zu keinem Link, bis es wiederhergestellt ist. + `SELECT si.item_id FROM share_items si JOIN items i ON i.id = si.item_id + WHERE si.share_id=$1 AND si.item_id=$2 AND i.deleted_at IS NULL`, [shareId, itemId]); return !!r; } @@ -235,7 +237,7 @@ export async function listShares(userId: string | null, istAdmin: boolean): Prom // über share_views: Bei vielen Zugriffen wurde diese Seite sonst zäh. `SELECT s.*, (SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id - WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS anzahl, + WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL) AS anzahl, s.besucher FROM shares s WHERE $2::boolean OR s.created_by = $1 diff --git a/src/lib/trash.ts b/src/lib/trash.ts new file mode 100644 index 0000000..2267f05 --- /dev/null +++ b/src/lib/trash.ts @@ -0,0 +1,101 @@ +// Der Papierkorb: sanft löschen, wiederherstellen, leeren — und die Wartung erledigt den Rest. +import { one, query } from './db.ts'; +import { deleteObject } from './storage.ts'; +import type { Viewer } from './itemaccess.ts'; + +export const DEFAULT_TRASH_DAYS = 30; + +/** Jede gespeicherte Datei, auf die eine Bildzeile zeigt. */ +export function objectKeysOf(it: { + source_path?: string | null; source_paths?: string[] | null; result_path?: string | null; thumb_path?: string | null; +}): string[] { + const keys = [it.source_path, it.result_path, it.thumb_path, ...(it.source_paths || [])] + .filter((k): k is string => typeof k === 'string' && k.length > 0); + return [...new Set(keys)]; +} + +/** + * Tage, bis ein Bild im Papierkorb endgültig verschwindet. Aufgerundet, nie unter + * null: „0 Tage" heißt, der nächste Wartungslauf nimmt es mit. + */ +export function daysLeft(deletedAt: Date | string, trashDays: number, now: Date = new Date()): number { + const gone = new Date(deletedAt).getTime() + trashDays * 86_400_000; + return Math.max(0, Math.ceil((gone - now.getTime()) / 86_400_000)); +} + +export async function trashDays(): Promise { + const s = await one<{ trash_days: number | null }>('SELECT trash_days FROM settings WHERE id=1'); + const d = s?.trash_days; + return typeof d === 'number' && d >= 0 ? d : DEFAULT_TRASH_DAYS; +} + +/** SQL-Bedingung: Zeilen, die dieses Konto ändern darf. `$1` = uid, `$2` = ist Admin. */ +const OWN = `($2::boolean OR i.job_id IN (SELECT id FROM jobs WHERE created_by = $1))`; + +/** Legt Bilder in den Papierkorb. Liefert die Kennungen, die tatsächlich verschoben wurden. */ +export async function trashItems(v: Viewer, ids: string[]): Promise { + if (!ids.length) return []; + if ((await trashDays()) === 0) return purgeItems(v, ids, { onlyTrashed: false }); + const rows = await query<{ id: string }>( + `UPDATE items i SET deleted_at = now() + WHERE i.id = ANY($3::uuid[]) AND i.deleted_at IS NULL AND ${OWN} + RETURNING i.id`, [v.uid, v.role === 'admin', ids]); + return rows.map((r) => r.id); +} + +export async function restoreItems(v: Viewer, ids: string[]): Promise { + if (!ids.length) return []; + const rows = await query<{ id: string }>( + `UPDATE items i SET deleted_at = NULL + WHERE i.id = ANY($3::uuid[]) AND i.deleted_at IS NOT NULL AND ${OWN} + RETURNING i.id`, [v.uid, v.role === 'admin', ids]); + return rows.map((r) => r.id); +} + +/** + * Entfernt Bilder endgültig: die Zeile und jede Datei, die kein anderes Bild mehr + * nutzt. Das Zweite ist wichtig — eine Alternative teilt ihre Quelldatei mit dem + * Original, und das Original zu löschen darf der Alternative nicht die Quelle + * wegnehmen. + */ +export async function purgeItems(v: Viewer | null, ids: string[], opts: { onlyTrashed?: boolean } = {}): Promise { + if (!ids.length) return []; + const onlyTrashed = opts.onlyTrashed !== false; + const admin = !v || v.role === 'admin'; + const rows = await query<{ id: string; source_path: string | null; source_paths: string[] | null; result_path: string | null; thumb_path: string | null }>( + `DELETE FROM items i + WHERE i.id = ANY($3::uuid[]) ${onlyTrashed ? 'AND i.deleted_at IS NOT NULL' : ''} AND ${OWN} + RETURNING i.id, i.source_path, i.source_paths, i.result_path, i.thumb_path`, + [v?.uid ?? null, admin, ids]); + for (const r of rows) { + for (const k of objectKeysOf(r)) { + // `source_paths` ist jsonb, kein Postgres-Array. Ein Fehler hier darf die + // Schleife nicht abbrechen: Die Zeilen sind schon weg, und eine übersprungene + // Datei ist nur verschenkter Platz — eine Ausnahme dagegen überspränge jede + // weitere Datei. + try { + const still = await one<{ n: number }>( + `SELECT count(*)::int AS n FROM items + WHERE source_path = $1 OR result_path = $1 OR thumb_path = $1 + OR source_paths @> jsonb_build_array($1::text)`, [k]); + if (!still?.n) await deleteObject(k); + } catch (e) { console.error('[papierkorb] konnte nicht entfernen', k, (e as Error)?.message); } + } + } + return rows.map((r) => r.id); +} + +/** Leert den Papierkorb: die eigenen Bilder, als Admin alle. */ +export async function emptyTrash(v: Viewer): Promise { + const ids = await query<{ id: string }>( + `SELECT i.id FROM items i WHERE i.deleted_at IS NOT NULL AND ${OWN}`, [v.uid, v.role === 'admin']); + return (await purgeItems(v, ids.map((r) => r.id))).length; +} + +/** Wartung: alles, was länger im Papierkorb liegt, als die Einstellung erlaubt. */ +export async function purgeExpiredTrash(): Promise { + const days = await trashDays(); + const ids = await query<{ id: string }>( + `SELECT id FROM items WHERE deleted_at IS NOT NULL AND deleted_at < now() - ($1 || ' days')::interval`, [String(days)]); + return (await purgeItems(null, ids.map((r) => r.id))).length; +} diff --git a/src/pages/anleitung.astro b/src/pages/anleitung.astro index eac38bd..1c81af9 100644 --- a/src/pages/anleitung.astro +++ b/src/pages/anleitung.astro @@ -55,6 +55,9 @@ import Base from '../layouts/Base.astro'; (die Bilder bleiben erhalten).
  • Mehrere auf einmal: Bilder markieren, dann als ZIP laden oder gesammelt in einen Ordner verschieben.
  • +
  • Löschen (✕) legt ein Bild in den Papierkorb. Direkt danach steht unten „Rückgängig". Im + Papierkorb (Knopf oben rechts, sobald etwas drinliegt) bleibt es 30 Tage und lässt sich wiederherstellen — + solange es dort liegt, taucht es in keinem geteilten Link und bei keiner Auslieferung auf.
  • Dateinamen sind eindeutig. Jedes Ergebnis heißt JJJJ-MM-TT_HHMMSS_motiv_format_kürzel, also z. B. diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index 21d057b..9dcef6d 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -42,6 +42,7 @@ export const GET: APIRoute = async () => { smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null, // Teilen share_default_days: s.share_default_days, + trash_days: s.trash_days, } }); }; @@ -64,9 +65,16 @@ export const PATCH: APIRoute = async ({ request }) => { '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', - 'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) { + 'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days', 'trash_days']) { if (col in b) { const raw = b[col]; + if (col === 'trash_days') { + // In der Datenbank NOT NULL: leer heißt Standard, sonst ganze Tage. + const n = raw === '' || raw == null ? 30 : Math.round(Number(raw)); + if (!Number.isFinite(n) || n < 0 || n > 3650) return json({ error: 'Papierkorb: 0 bis 3650 Tage.' }, 400); + set(col, n); + continue; + } const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw); set(col, val); } diff --git a/src/pages/api/admin/stats.ts b/src/pages/api/admin/stats.ts index 658473e..4b59bc7 100644 --- a/src/pages/api/admin/stats.ts +++ b/src/pages/api/admin/stats.ts @@ -4,7 +4,7 @@ import { query, one } from '../../../lib/db'; export const prerender = false; export const GET: APIRoute = async () => { - const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`); + const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done' AND deleted_at IS NULL`); const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`); const cost = async (days: number | null) => (await one<{ c: string }>( `SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` + diff --git a/src/pages/api/folders/index.ts b/src/pages/api/folders/index.ts index e03ffdb..90a4789 100644 --- a/src/pages/api/folders/index.ts +++ b/src/pages/api/folders/index.ts @@ -7,7 +7,7 @@ const json = (b: unknown, s = 200) => export const GET: APIRoute = async ({ locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); - const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id)::int AS count + const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id AND deleted_at IS NULL)::int AS count FROM folders f ORDER BY f.name`); return json({ folders: rows }); }; diff --git a/src/pages/api/items/[id].ts b/src/pages/api/items/[id].ts index fe7ba38..80c156b 100644 --- a/src/pages/api/items/[id].ts +++ b/src/pages/api/items/[id].ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; -import { one, query } from '../../../lib/db'; -import { deleteObject } from '../../../lib/storage'; +import { one } from '../../../lib/db'; +import { loadItemFor } from '../../../lib/itemaccess'; +import { trashItems, purgeItems } from '../../../lib/trash'; export const prerender = false; const json = (b: unknown, s = 200) => @@ -8,7 +9,8 @@ const json = (b: unknown, s = 200) => export const PATCH: APIRoute = async ({ params, request, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); - const b = await request.json(); + if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Nicht gefunden.' }, 404); + const b = await request.json().catch(() => ({} as any)); const sets: string[] = []; const args: any[] = []; if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); } if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); } @@ -22,10 +24,20 @@ export const PATCH: APIRoute = async ({ params, request, locals }) => { return json({ item: row }); }; -export const DELETE: APIRoute = async ({ params, locals }) => { +/** + * Legt das Bild in den Papierkorb. `?purge=1` entfernt ein Bild, das schon im + * Papierkorb liegt, endgültig. Ist der Papierkorb abgeschaltet (trash_days = 0), + * ist Löschen sofort endgültig, wie früher. + */ +export const DELETE: APIRoute = async ({ params, url, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); - const it = await one<{ result_path: string | null }>('SELECT result_path FROM items WHERE id=$1', [params.id]); - if (it?.result_path) await deleteObject(it.result_path).catch(() => {}); - await query('DELETE FROM items WHERE id=$1', [params.id]); - return json({ ok: true }); + const purge = url.searchParams.get('purge') === '1'; + const it = await loadItemFor(locals.user, params.id!, 'write', { includeTrashed: purge }); + if (!it) return json({ error: 'Nicht gefunden.' }, 404); + if (purge) { + const gone = await purgeItems(locals.user, [it.id]); + return json({ ok: gone.length === 1, purged: gone.length }); + } + const moved = await trashItems(locals.user, [it.id]); + return json({ ok: moved.length === 1, trashed: moved.length }); }; diff --git a/src/pages/api/items/[id]/alternative.ts b/src/pages/api/items/[id]/alternative.ts index b7c90f2..f8480e4 100644 --- a/src/pages/api/items/[id]/alternative.ts +++ b/src/pages/api/items/[id]/alternative.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; import { one, query } from '../../../../lib/db'; import { enqueue } from '../../../../lib/queue'; +import { loadItemFor } from '../../../../lib/itemaccess'; export const prerender = false; const json = (b: unknown, s = 200) => @@ -9,6 +10,7 @@ const json = (b: unknown, s = 200) => /** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */ export const POST: APIRoute = async ({ params, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); + if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Nicht gefunden.' }, 404); const item = await one( `SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]); if (!item) return json({ error: 'Nicht gefunden.' }, 404); diff --git a/src/pages/api/items/[id]/retry.ts b/src/pages/api/items/[id]/retry.ts index 916fd91..613babe 100644 --- a/src/pages/api/items/[id]/retry.ts +++ b/src/pages/api/items/[id]/retry.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; import { one, query } from '../../../../lib/db'; import { enqueue } from '../../../../lib/queue'; +import { loadItemFor } from '../../../../lib/itemaccess'; export const prerender = false; const json = (b: unknown, s = 200) => @@ -8,6 +9,7 @@ const json = (b: unknown, s = 200) => export const POST: APIRoute = async ({ params, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); + if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Nicht gefunden.' }, 404); const item = await one<{ id: string; job_id: string }>( 'SELECT id, job_id FROM items WHERE id=$1', [params.id]); if (!item) return json({ error: 'Nicht gefunden.' }, 404); diff --git a/src/pages/api/items/[id]/reuse.ts b/src/pages/api/items/[id]/reuse.ts index dd64432..e3c39d3 100644 --- a/src/pages/api/items/[id]/reuse.ts +++ b/src/pages/api/items/[id]/reuse.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { randomUUID } from 'node:crypto'; import { one } from '../../../../lib/db'; import { getObject, putObject, sourceKey } from '../../../../lib/storage'; +import { loadItemFor } from '../../../../lib/itemaccess'; export const prerender = false; const json = (b: unknown, s = 200) => @@ -11,6 +12,7 @@ const json = (b: unknown, s = 200) => * als Vorlage weiterbearbeitet werden kann. */ export const POST: APIRoute = async ({ params, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); + if (!(await loadItemFor(locals.user, params.id!, 'read'))) return json({ error: 'Kein Ergebnis vorhanden.' }, 404); const it = await one<{ result_path: string | null; filename: string | null }>( 'SELECT result_path, filename FROM items WHERE id=$1', [params.id]); if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404); diff --git a/src/pages/api/items/index.ts b/src/pages/api/items/index.ts index 1a791b1..95e837c 100644 --- a/src/pages/api/items/index.ts +++ b/src/pages/api/items/index.ts @@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ url, locals }) => { const isAdmin = locals.user.role === 'admin'; const shared = s?.library_visibility === 'shared'; // Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung. - const where: string[] = [`i.status='done'`, `j.private=false`]; + const where: string[] = [`i.status='done'`, `j.private=false`, `i.deleted_at IS NULL`]; const args: any[] = []; const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); }; if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid); diff --git a/src/pages/api/items/zip.ts b/src/pages/api/items/zip.ts index fd2b62e..41de41c 100644 --- a/src/pages/api/items/zip.ts +++ b/src/pages/api/items/zip.ts @@ -14,7 +14,13 @@ export const POST: APIRoute = async ({ request, locals }) => { if (!ids.length) return new Response('Keine Auswahl.', { status: 400 }); const rows = await query<{ id: string; result_path: string | null; filename: string | null }>( - `SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]); + // Nur Bilder, die dieses Konto sehen darf, und keine aus dem Papierkorb. + // Vorher landete jede Kennung jedes Kontos im Archiv. + `SELECT i.id, i.result_path, i.filename FROM items i JOIN jobs j ON j.id = i.job_id + WHERE i.id = ANY($1::uuid[]) AND i.result_path IS NOT NULL AND i.deleted_at IS NULL + AND ($3::boolean OR j.created_by = $2 + OR (NOT j.private AND (SELECT library_visibility FROM settings WHERE id=1) = 'shared'))`, + [ids.filter((x) => /^[0-9a-f-]{36}$/i.test(String(x))), locals.user.uid, locals.user.role === 'admin']); if (!rows.length) return new Response('Keine Ergebnisse.', { status: 404 }); const archive = new ZipArchive({ zlib: { level: 6 } }); diff --git a/src/pages/api/jobs/[id]/[action].ts b/src/pages/api/jobs/[id]/[action].ts index ee4920f..a73cf52 100644 --- a/src/pages/api/jobs/[id]/[action].ts +++ b/src/pages/api/jobs/[id]/[action].ts @@ -29,7 +29,7 @@ export const POST: APIRoute = async ({ params, locals }) => { break; case 'retry-failed': { const items = await query<{ id: string }>( - `UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' RETURNING id`, [id]); + `UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' AND deleted_at IS NULL RETURNING id`, [id]); await query(`UPDATE jobs SET status='queued', failed_count=0 WHERE id=$1`, [id]); for (const it of items) await enqueue({ itemId: it.id, jobId: id }); break; diff --git a/src/pages/api/s/[slug]/zip.ts b/src/pages/api/s/[slug]/zip.ts index e5917df..b3321aa 100644 --- a/src/pages/api/s/[slug]/zip.ts +++ b/src/pages/api/s/[slug]/zip.ts @@ -60,7 +60,7 @@ export const GET: APIRoute = async ({ params, request, clientAddress }) => { const pfade = await query<{ id: string; result_path: string; filename: string | null }>( `SELECT id, result_path, filename FROM items - WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`, + WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL AND deleted_at IS NULL`, [bilder.map((b) => b.id)]); const nachId = new Map(pfade.map((p) => [p.id, p])); diff --git a/src/pages/api/shares/index.ts b/src/pages/api/shares/index.ts index 877ed23..a2ebef0 100644 --- a/src/pages/api/shares/index.ts +++ b/src/pages/api/shares/index.ts @@ -34,15 +34,15 @@ export const POST: APIRoute = async ({ request, locals }) => { const istAdmin = locals.user.role === 'admin'; // Eine Abfrage statt einer je Bild: bei 500 Bildern waren das 500 Rundreisen. - const gefunden = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>( - `SELECT i.id, i.status, i.result_path, j.created_by, j.private, + const gefunden = await query<{ id: string; status: string; result_path: string | null; deleted_at: string | null; created_by: string | null; private: boolean; library_visibility: string }>( + `SELECT i.id, i.status, i.result_path, i.deleted_at, j.created_by, j.private, (SELECT library_visibility FROM settings WHERE id=1) AS library_visibility FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = ANY($1::uuid[])`, [ids]); if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404); for (const it of gefunden) { - if (!it.result_path || it.status !== 'done') return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404); + if (!it.result_path || it.status !== 'done' || it.deleted_at) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404); // 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); diff --git a/src/pages/api/trash.ts b/src/pages/api/trash.ts new file mode 100644 index 0000000..3753eb1 --- /dev/null +++ b/src/pages/api/trash.ts @@ -0,0 +1,38 @@ +import type { APIRoute } from 'astro'; +import { query } from '../../lib/db'; +import { trashItems, restoreItems, purgeItems, emptyTrash, trashDays, daysLeft } from '../../lib/trash'; + +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-]{36}$/i; + +/** Was im Papierkorb liegt — die eigenen Bilder, als Admin alle. */ +export const GET: APIRoute = async ({ locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const days = await trashDays(); + const rows = await query( + `SELECT i.id, i.filename, i.output_px, i.has_alpha, i.thumb_path, i.deleted_at, i.created_at + FROM items i JOIN jobs j ON j.id = i.job_id + WHERE i.deleted_at IS NOT NULL AND ($2::boolean OR j.created_by = $1) + ORDER BY i.deleted_at DESC LIMIT 400`, [locals.user.uid, locals.user.role === 'admin']); + return json({ days, items: rows.map((r) => ({ ...r, days_left: daysLeft(r.deleted_at, days) })) }); +}; + +/** Body { action: 'trash' | 'restore' | 'purge', ids: [...] } — mehrere auf einmal, eine Rundreise. */ +export const POST: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const b = await request.json().catch(() => ({} as any)); + const ids: string[] = Array.isArray(b.ids) ? b.ids.filter((x: unknown) => typeof x === 'string' && UUID.test(x)).slice(0, 500) : []; + if (!ids.length) return json({ error: 'Keine Bilder ausgewählt.' }, 400); + if (b.action === 'trash') return json({ ok: true, ids: await trashItems(locals.user, ids) }); + if (b.action === 'restore') return json({ ok: true, ids: await restoreItems(locals.user, ids) }); + if (b.action === 'purge') return json({ ok: true, ids: await purgeItems(locals.user, ids) }); + return json({ error: 'Unbekannte Aktion.' }, 400); +}; + +/** Leert den Papierkorb. */ +export const DELETE: APIRoute = async ({ locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + return json({ ok: true, purged: await emptyTrash(locals.user) }); +}; diff --git a/src/pages/changelog.astro b/src/pages/changelog.astro index 8bf9791..0eaa7dd 100644 --- a/src/pages/changelog.astro +++ b/src/pages/changelog.astro @@ -9,6 +9,24 @@ import Base from '../layouts/Base.astro';

    Was in Klarbild neu ist. Neueste Änderungen oben.

    +
    +
    10.10.2026

    Ein Papierkorb, und ein Symbol fürs iPhone

    +
      +
    • Löschen ist nicht mehr sofort endgültig. ✕ legt ein Bild in den Papierkorb, und unten steht für + ein paar Sekunden „Rückgängig". Wer es später merkt: Oben rechts in der Bibliothek erscheint + Papierkorb, dort lässt sich jedes Bild 30 Tage lang wiederherstellen. Danach räumt Klarbild es + von selbst weg.
    • +
    • Solange ein Bild im Papierkorb liegt, ist es auch aus geteilten Links verschwunden — und kommt + beim Wiederherstellen dorthin zurück.
    • +
    • Wer ein Bild löscht, das noch in der Warteschlange wartet, bezahlt dafür nichts mehr: Es wird gar nicht + erst erzeugt.
    • +
    • Eigenes Symbol. Im Browser-Tab und auf dem iPhone-Homebildschirm („Zum Home-Bildschirm") steht + jetzt die Passermarke statt einer leeren Seite.
    • +
    • Behoben: Druckbogen, die der Telegram-Bot verschickt, hatten keine Fußzeile — also auch nicht den + Hinweis, mit 100 % zu drucken. Jetzt wieder.
    • +
    +
    +
    01.09.2026

    Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast

      diff --git a/tests/trash.test.ts b/tests/trash.test.ts new file mode 100644 index 0000000..243aec1 --- /dev/null +++ b/tests/trash.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { objectKeysOf, daysLeft } from '../src/lib/trash.ts'; +import { canRead, canWrite } from '../src/lib/itemaccess.ts'; + +test('Every stored file of an image is found, each once', () => { + assert.deepEqual( + objectKeysOf({ source_path: 'sources/a.png', result_path: 'results/a.png', thumb_path: 'thumbs/a.webp', + source_paths: ['sources/a.png', 'sources/b.png'] }), + ['sources/a.png', 'results/a.png', 'thumbs/a.webp', 'sources/b.png']); + assert.deepEqual(objectKeysOf({ source_path: null, result_path: '', thumb_path: undefined, source_paths: null }), []); +}); + +test('Days left in the trash count down and never go negative', () => { + const now = new Date('2026-10-10T12:00:00Z'); + assert.equal(daysLeft('2026-10-10T12:00:00Z', 30, now), 30); + assert.equal(daysLeft('2026-10-09T12:00:00Z', 30, now), 29); + assert.equal(daysLeft('2026-10-09T11:00:00Z', 30, now), 29); // 28.96 days -> rounded up + assert.equal(daysLeft('2026-09-10T12:00:00Z', 30, now), 0); // exactly due + assert.equal(daysLeft('2026-01-01T00:00:00Z', 30, now), 0); // long overdue + assert.equal(daysLeft('2026-10-10T12:00:00Z', 0, now), 0); // trash switched off +}); + +test('Reading: own, admin, or a shared library — never another person\'s private session', () => { + const me = { uid: 'u1', role: 'user' }, admin = { uid: 'a', role: 'admin' }; + const mine = { created_by: 'u1', private: false }, theirs = { created_by: 'u2', private: false }; + const theirsPrivate = { created_by: 'u2', private: true }; + assert.equal(canRead(me, mine, false), true); + assert.equal(canRead(me, theirs, false), false); + assert.equal(canRead(me, theirs, true), true); + assert.equal(canRead(me, theirsPrivate, true), false); + assert.equal(canRead(admin, theirsPrivate, false), true); +}); + +test('Writing: own or admin only — a shared library is for looking, not for deleting', () => { + const me = { uid: 'u1', role: 'user' }; + assert.equal(canWrite(me, { created_by: 'u1' }), true); + assert.equal(canWrite(me, { created_by: 'u2' }), false); + assert.equal(canWrite({ uid: null, role: 'user' }, { created_by: null }), false); + assert.equal(canWrite({ uid: 'x', role: 'admin' }, { created_by: 'u2' }), true); +});