Papierkorb statt endgültigem Löschen, und Zugriffsregeln für jede Bild-Route

Löschen in der Bibliothek entfernte Zeile und Ergebnisdatei sofort. Ein
Fehlklick, und ein Ergebnis war weg — samt dem, was es gekostet hat.
Jetzt landet es im Papierkorb:

- weg aus Bibliothek, Freigabelinks, ZIPs, Auslieferung, NAS-Spiegel,
  Druckquellen und Druckverlauf; beim Wiederherstellen unverändert zurück
- „Rückgängig" im Hinweis, Papierkorb-Ansicht mit Resttagen,
  Wiederherstellen, endgültig löschen, leeren
- die Wartung räumt nach settings.trash_days (Standard 30, Admin ->
  Speicher) endgültig ab; 0 schaltet den Papierkorb aus
- ein Bild, das noch in der Warteschlange wartet, wird nicht mehr
  erzeugt (und nicht bezahlt)
- endgültig gelöscht wird nur, was kein anderes Bild mehr nutzt —
  Alternativen teilen ihre Quelle mit dem Original, auch Kombinieren-
  Quellen in source_paths (jsonb)

Beim Durchgehen jeder Abfrage auf items fiel auf: Nur die Datei-Route
prüfte, wer ein Bild sehen darf. Umbenennen, Löschen, Wiederholen,
Alternative, Weiterbearbeiten und die Auswahl-ZIP nahmen jede Kennung von
jedem Konto. Jetzt gilt eine Regel (src/lib/itemaccess.ts): sehen, was
einem gehört oder in der geteilten Bibliothek liegt, ändern nur Eigenes;
Admins alles. Für Till ändert sich nichts. Lea kann fremde Bilder in der
geteilten Bibliothek weiter sehen, aber nicht mehr löschen.

Dazu Favicon und Homebildschirm-Symbol, Hilfe und Changelog-Seite.

Geprüft gegen echtes Postgres mit zwei Konten und echten Dateien:
35 Ende-zu-Ende-Prüfungen bestanden, Oberfläche im Browser (Löschen,
Rückgängig, Papierkorb, Wiederherstellen, 390 px ohne Querlauf).
128 Tests, Typprüfung 0 Fehler.
This commit is contained in:
till committed 2026-10-10 06:38:42 +00:00
1 parent ee887cc29c
commit 28a6049548
33 files changed
+456 -40

No files matched your search

+31
View File
@@ -2,6 +2,37 @@
All notable changes to Klarbild are documented here. Newest first. 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 ## 2026-09-01 (later) — Unique names for print files
### Fixed ### Fixed
+1 -1
View File
@@ -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). `Imagetool-Lea/` (00,01,03,06,07; 02/04/05 stehen noch aus).
## Stack (fest) ## 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. 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. Kein Tailwind. OKLCH-Tokens in `src/styles/tokens.css`. Gitea → Coolify → Hetzner.
+13
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#F6F6F1"/><circle cx="16" cy="16" r="8.5" fill="none" stroke="#1B3BE0" stroke-width="2.2"/><path d="M16 3.5v25M3.5 16h25" stroke="#1B3BE0" stroke-width="2.2" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 291 B

+4
View File
@@ -82,6 +82,7 @@ export default function AdminApp() {
output_ext: s.output_ext || 'png', 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, 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, 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, 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, library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
@@ -317,6 +318,9 @@ export default function AdminApp() {
<div className="feld"><label>Aufbewahrung (Tage)</label> <div className="feld"><label>Aufbewahrung (Tage)</label>
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} /> <input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div> <div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
<div className="feld"><label>Papierkorb (Tage)</label>
<input className="input" type="number" min={0} max={3650} value={s.trash_days ?? 30} onChange={(e) => field('trash_days', e.target.value)} />
<div className="fein">So lange bleiben gelöschte Bilder im Papierkorb und lassen sich bis dahin wiederherstellen. 0 = kein Papierkorb, Löschen ist endgültig.</div></div>
<div className="reihe"> <div className="reihe">
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button> <button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button> <button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
+83 -11
View File
@@ -28,8 +28,16 @@ export default function LibraryApp() {
const [tagMenu, setTagMenu] = useState<string | null>(null); const [tagMenu, setTagMenu] = useState<string | null>(null);
const [teilen, setTeilen] = useState<ShareZiel | null>(null); const [teilen, setTeilen] = useState<ShareZiel | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null); const [tagFilter, setTagFilter] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<{ msg: string; undo?: () => void } | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; const toastTimer = React.useRef<ReturnType<typeof setTimeout> | 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. // Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
const modelName = (id: string | null) => { const modelName = (id: string | null) => {
@@ -40,12 +48,14 @@ export default function LibraryApp() {
}; };
const load = useCallback(async () => { 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/items').then((r) => r.json()).catch(() => ({ items: [] })),
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })), fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })), 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 || []); setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
setKorb({ days: k.days ?? 30, items: k.items || [] });
}, []); }, []);
useEffect(() => { load(); }, [load]); 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 }) }); await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
load(); load();
}; };
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); }; const korbAktion = (action: 'trash' | 'restore' | 'purge', ids: string[]) =>
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); }; 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 () => { const deliverSelected = async () => {
notify('Sende an Picdrop …'); 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()); 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 ( return (
<div> <div>
<div className="kopfzeile"> <div className="kopfzeile">
<span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span> <span className="mono-label">{korbAnsicht ? `Papierkorb · ${korb.items.length}` : <>Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</>}</span>
<div className="reihe"> <div className="reihe">
{pick.size > 0 && <> {pick.size > 0 && !korbAnsicht && <>
<span className="fein">{pick.size} gewählt</span> <span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button> <button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button> <button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button>
@@ -143,10 +175,43 @@ export default function LibraryApp() {
</select> </select>
<button className="mini" onClick={delSelected}>Löschen</button> <button className="mini" onClick={delSelected}>Löschen</button>
</>} </>}
<button className="mini" onClick={newFolder}>Neuer Ordner</button> {!korbAnsicht && <button className="mini" onClick={newFolder}>Neuer Ordner</button>}
{(korb.items.length > 0 || korbAnsicht) && (
<button className={`mini ${korbAnsicht ? 'stark' : ''}`} onClick={() => setKorbAnsicht(!korbAnsicht)}>
{korbAnsicht ? 'Zurück zur Bibliothek' : `Papierkorb · ${korb.items.length}`}</button>
)}
</div> </div>
</div> </div>
{korbAnsicht ? (
<div className="korb">
<div className="fein hinweis">{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.'}</div>
{korb.items.length === 0 ? (
<div className="leer"><span className="regmark" /><h3>Der Papierkorb ist leer</h3></div>
) : (<>
<div className="reihe" style={{ marginBottom: 12 }}>
<button className="mini" onClick={() => wiederherstellen(korb.items.map((x) => x.id))}>Alle wiederherstellen</button>
<button className="mini gefahr" onClick={korbLeeren}>Papierkorb leeren</button>
</div>
<div className="galerie">
{korb.items.map((v) => (
<article key={v.id} className="kachel geloescht">
<div className="kachel-bild"><img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" /></div>
<div className="name fest">{v.filename || '—'}</div>
<div className="meta">{v.days_left === 0 ? 'Wird beim nächsten Aufräumen entfernt' : v.days_left === 1 ? 'Noch 1 Tag' : `Noch ${v.days_left} Tage`}</div>
<div className="reihe knapp">
<button className="mini stark2" onClick={() => wiederherstellen([v.id])}>Wiederherstellen</button>
<button className="mini" onClick={() => endgueltig([v.id])}>Endgültig löschen</button>
</div>
</article>
))}
</div>
</>)}
</div>
) : (<>
{/* Ordner-Filter */} {/* Ordner-Filter */}
<div className="ordnerleiste"> <div className="ordnerleiste">
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button> <button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
@@ -236,7 +301,7 @@ export default function LibraryApp() {
<option value="">Kein Ordner</option> <option value="">Kein Ordner</option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)} {folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select> </select>
<button className="mini" onClick={() => del(v.id)}>✕</button> <button className="mini" onClick={() => del([v.id])} title="In den Papierkorb">✕</button>
</div> </div>
</article> </article>
); );
@@ -244,6 +309,8 @@ export default function LibraryApp() {
</div> </div>
)} )}
</>)}
{teilen && ( {teilen && (
<ShareDialog ziel={teilen} onClose={() => setTeilen(null)} /> <ShareDialog ziel={teilen} onClose={() => setTeilen(null)} />
)} )}
@@ -282,7 +349,8 @@ export default function LibraryApp() {
</div> </div>
</div> </div>
)} )}
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast" role="status">{toast.msg}
{toast.undo && <button className="toast-undo" onClick={toast.undo}>Rückgängig</button>}</div>}
<LibStyles /> <LibStyles />
</div> </div>
); );
@@ -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;} .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-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;} .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;}
`}</style>; `}</style>;
} }
+2
View File
@@ -42,6 +42,8 @@ const ICONS: Record<string, string> = {
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#EAEAE3" /> <meta name="theme-color" content="#EAEAE3" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body> <body>
+1 -1
View File
@@ -63,7 +63,7 @@ export async function backupAll(): Promise<{ items: number; mirrored: number; fa
const dests = await backupDestinations(); const dests = await backupDestinations();
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 }; if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
const rows = await query<{ id: string }>( 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; let mirrored = 0, failed = 0;
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; } for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
return { items: rows.length, mirrored, failed }; return { items: rows.length, mirrored, failed };
+1 -1
View File
@@ -80,6 +80,6 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag
/** Alle noch offenen (pending) Auslieferungen eines Auftrags abarbeiten. */ /** Alle noch offenen (pending) Auslieferungen eines Auftrags abarbeiten. */
export async function deliverPendingForJob(jobId: string): Promise<void> { export async function deliverPendingForJob(jobId: string): Promise<void> {
const items = await query<{ id: string }>( 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); for (const it of items) await deliverItem(it.id);
} }
+49
View File
@@ -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<ItemAccessRow, 'created_by' | 'private'>, 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<ItemAccessRow, 'created_by'>): 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<ItemAccessRow | null> {
if (!/^[0-9a-f-]{36}$/i.test(id)) return null;
const it = await one<ItemAccessRow & { library_visibility: string | null }>(
`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;
}
+3
View File
@@ -138,6 +138,9 @@ export function startMaintenance(): void {
const r = await runRetention(s.retention_days); const r = await runRetention(s.retention_days);
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`); 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); const p = await purgePrivate(60);
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`); if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
const t = await purgeTracking(90); const t = await purgeTracking(90);
+2 -2
View File
@@ -99,7 +99,7 @@ export async function recordRun(e: RunEingang): Promise<string | null> {
await query( await query(
`INSERT INTO print_run_items (run_id, item_id) `INSERT INTO print_run_items (run_id, item_id)
SELECT $1, x FROM unnest($2::uuid[]) AS x 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) => ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || 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 }>( const bilder = await query<{ run_id: string; id: string; filename: string | null }>(
`SELECT ri.run_id, i.id, i.filename `SELECT ri.run_id, i.id, i.filename
FROM print_run_items ri JOIN items i ON i.id = ri.item_id 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)]); [runs.map((r) => r.id)]);
const nachRun = new Map<string, { id: string; filename: string | null }[]>(); const nachRun = new Map<string, { id: string; filename: string | null }[]>();
+1 -1
View File
@@ -37,7 +37,7 @@ export async function loadSourceNamed(src: PrintSource, user: SessionUser): Prom
if (src.kind === 'item') { if (src.kind === 'item') {
const item = await one<any>( const item = await one<any>(
`SELECT i.result_path, i.filename, j.created_by, j.private `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.'); if (!item?.result_path) throw new Error('Bild nicht gefunden.');
const isAdmin = user.role === 'admin'; const isAdmin = user.role === 'admin';
const own = item.created_by === user.uid; const own = item.created_by === user.uid;
+8 -2
View File
@@ -61,9 +61,15 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; }
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
export async function processItem(itemId: string): Promise<ProcessResult> { export async function processItem(itemId: string): Promise<ProcessResult> {
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>( 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 FROM items WHERE id=$1', [itemId]); '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' }; 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 }>( const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]); 'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
+5 -3
View File
@@ -155,14 +155,16 @@ export async function shareItems(shareId: string): Promise<ShareBild[]> {
`SELECT i.id, i.filename, i.output_px, i.created_at `SELECT i.id, i.filename, i.output_px, i.created_at
FROM share_items si FROM share_items si
JOIN items i ON i.id = si.item_id 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]); ORDER BY si.position, i.created_at`, [shareId]);
} }
/** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */ /** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> { export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
const r = await one<{ item_id: string }>( 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; 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. // über share_views: Bei vielen Zugriffen wurde diese Seite sonst zäh.
`SELECT s.*, `SELECT s.*,
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id (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 s.besucher
FROM shares s FROM shares s
WHERE $2::boolean OR s.created_by = $1 WHERE $2::boolean OR s.created_by = $1
+101
View File
@@ -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<number> {
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<string[]> {
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<string[]> {
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<string[]> {
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<number> {
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<number> {
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;
}
+3
View File
@@ -55,6 +55,9 @@ import Base from '../layouts/Base.astro';
(die Bilder bleiben erhalten).</li> (die Bilder bleiben erhalten).</li>
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b> <li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
verschieben.</li> verschieben.</li>
<li><b>Löschen</b> (✕) legt ein Bild in den <b>Papierkorb</b>. 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.</li>
</ul> </ul>
<p class="hinweis"><b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt <p class="hinweis"><b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt
<code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B. <code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B.
+9 -1
View File
@@ -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, smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
// Teilen // Teilen
share_default_days: s.share_default_days, 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', 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw', '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']) { 'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days', 'trash_days']) {
if (col in b) { if (col in b) {
const raw = b[col]; 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); const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
set(col, val); set(col, val);
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { query, one } from '../../../lib/db';
export const prerender = false; export const prerender = false;
export const GET: APIRoute = async () => { 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 failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
const cost = async (days: number | null) => (await one<{ c: string }>( const cost = async (days: number | null) => (await one<{ c: string }>(
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` + `SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
+1 -1
View File
@@ -7,7 +7,7 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async ({ locals }) => { export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); 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`); FROM folders f ORDER BY f.name`);
return json({ folders: rows }); return json({ folders: rows });
}; };
+20 -8
View File
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db'; import { one } from '../../../lib/db';
import { deleteObject } from '../../../lib/storage'; import { loadItemFor } from '../../../lib/itemaccess';
import { trashItems, purgeItems } from '../../../lib/trash';
export const prerender = false; export const prerender = false;
const json = (b: unknown, s = 200) => const json = (b: unknown, s = 200) =>
@@ -8,7 +9,8 @@ const json = (b: unknown, s = 200) =>
export const PATCH: APIRoute = async ({ params, request, locals }) => { export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); 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[] = []; const sets: string[] = []; const args: any[] = [];
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); } 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}`); } 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 }); 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 }); 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]); const purge = url.searchParams.get('purge') === '1';
if (it?.result_path) await deleteObject(it.result_path).catch(() => {}); const it = await loadItemFor(locals.user, params.id!, 'write', { includeTrashed: purge });
await query('DELETE FROM items WHERE id=$1', [params.id]); if (!it) return json({ error: 'Nicht gefunden.' }, 404);
return json({ ok: true }); 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 });
}; };
+2
View File
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db'; import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue'; import { enqueue } from '../../../../lib/queue';
import { loadItemFor } from '../../../../lib/itemaccess';
export const prerender = false; export const prerender = false;
const json = (b: unknown, s = 200) => 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. */ /** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */
export const POST: APIRoute = async ({ params, locals }) => { export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); 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<any>( const item = await one<any>(
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]); `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); if (!item) return json({ error: 'Nicht gefunden.' }, 404);
+2
View File
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db'; import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue'; import { enqueue } from '../../../../lib/queue';
import { loadItemFor } from '../../../../lib/itemaccess';
export const prerender = false; export const prerender = false;
const json = (b: unknown, s = 200) => const json = (b: unknown, s = 200) =>
@@ -8,6 +9,7 @@ const json = (b: unknown, s = 200) =>
export const POST: APIRoute = async ({ params, locals }) => { export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); 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 }>( const item = await one<{ id: string; job_id: string }>(
'SELECT id, job_id FROM items WHERE id=$1', [params.id]); 'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
if (!item) return json({ error: 'Nicht gefunden.' }, 404); if (!item) return json({ error: 'Nicht gefunden.' }, 404);
+2
View File
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { one } from '../../../../lib/db'; import { one } from '../../../../lib/db';
import { getObject, putObject, sourceKey } from '../../../../lib/storage'; import { getObject, putObject, sourceKey } from '../../../../lib/storage';
import { loadItemFor } from '../../../../lib/itemaccess';
export const prerender = false; export const prerender = false;
const json = (b: unknown, s = 200) => const json = (b: unknown, s = 200) =>
@@ -11,6 +12,7 @@ const json = (b: unknown, s = 200) =>
* als Vorlage weiterbearbeitet werden kann. */ * als Vorlage weiterbearbeitet werden kann. */
export const POST: APIRoute = async ({ params, locals }) => { export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); 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 }>( const it = await one<{ result_path: string | null; filename: string | null }>(
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]); 'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404); if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404);
+1 -1
View File
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
const isAdmin = locals.user.role === 'admin'; const isAdmin = locals.user.role === 'admin';
const shared = s?.library_visibility === 'shared'; const shared = s?.library_visibility === 'shared';
// Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung. // 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 args: any[] = [];
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); }; 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); if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
+7 -1
View File
@@ -14,7 +14,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!ids.length) return new Response('Keine Auswahl.', { status: 400 }); if (!ids.length) return new Response('Keine Auswahl.', { status: 400 });
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>( 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 }); if (!rows.length) return new Response('Keine Ergebnisse.', { status: 404 });
const archive = new ZipArchive({ zlib: { level: 6 } }); const archive = new ZipArchive({ zlib: { level: 6 } });
+1 -1
View File
@@ -29,7 +29,7 @@ export const POST: APIRoute = async ({ params, locals }) => {
break; break;
case 'retry-failed': { case 'retry-failed': {
const items = await query<{ id: string }>( 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]); 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 }); for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break; break;
+1 -1
View File
@@ -60,7 +60,7 @@ export const GET: APIRoute = async ({ params, request, clientAddress }) => {
const pfade = await query<{ id: string; result_path: string; filename: string | null }>( const pfade = await query<{ id: string; result_path: string; filename: string | null }>(
`SELECT id, result_path, filename FROM items `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)]); [bilder.map((b) => b.id)]);
const nachId = new Map(pfade.map((p) => [p.id, p])); const nachId = new Map(pfade.map((p) => [p.id, p]));
+3 -3
View File
@@ -34,15 +34,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const istAdmin = locals.user.role === 'admin'; const istAdmin = locals.user.role === 'admin';
// Eine Abfrage statt einer je Bild: bei 500 Bildern waren das 500 Rundreisen. // 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 }>( 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, j.created_by, j.private, `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 (SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
FROM items i JOIN jobs j ON j.id = i.job_id FROM items i JOIN jobs j ON j.id = i.job_id
WHERE i.id = ANY($1::uuid[])`, [ids]); WHERE i.id = ANY($1::uuid[])`, [ids]);
if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404); if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
for (const it of gefunden) { 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 // Private Aufträge werden bewusst nie geteilt — sie sollen ja gerade
// keine Spur hinterlassen. // keine Spur hinterlassen.
if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403); if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403);
+38
View File
@@ -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<any>(
`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) });
};
+18
View File
@@ -9,6 +9,24 @@ import Base from '../layouts/Base.astro';
</div> </div>
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p> <p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
<section>
<div class="ver"><span class="tag">10.10.2026</span><h2>Ein Papierkorb, und ein Symbol fürs iPhone</h2></div>
<ul>
<li><b>Löschen ist nicht mehr sofort endgültig.</b> ✕ legt ein Bild in den Papierkorb, und unten steht für
ein paar Sekunden <b>„Rückgängig"</b>. Wer es später merkt: Oben rechts in der Bibliothek erscheint
<b>Papierkorb</b>, dort lässt sich jedes Bild 30 Tage lang wiederherstellen. Danach räumt Klarbild es
von selbst weg.</li>
<li>Solange ein Bild im Papierkorb liegt, ist es auch aus <b>geteilten Links</b> verschwunden — und kommt
beim Wiederherstellen dorthin zurück.</li>
<li>Wer ein Bild löscht, das noch in der Warteschlange wartet, bezahlt dafür nichts mehr: Es wird gar nicht
erst erzeugt.</li>
<li><b>Eigenes Symbol.</b> Im Browser-Tab und auf dem iPhone-Homebildschirm („Zum Home-Bildschirm") steht
jetzt die Passermarke statt einer leeren Seite.</li>
<li><b>Behoben:</b> Druckbogen, die der Telegram-Bot verschickt, hatten keine Fußzeile — also auch nicht den
Hinweis, mit 100 % zu drucken. Jetzt wieder.</li>
</ul>
</section>
<section> <section>
<div class="ver"><span class="tag">01.09.2026</span><h2>Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast</h2></div> <div class="ver"><span class="tag">01.09.2026</span><h2>Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast</h2></div>
<ul> <ul>
+41
View File
@@ -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);
});