diff --git a/migrations/017_trash.sql b/migrations/017_trash.sql new file mode 100644 index 0000000..7ba7615 --- /dev/null +++ b/migrations/017_trash.sql @@ -0,0 +1,12 @@ +-- Trash instead of deleting for good ---------------------------------------- +-- +-- Deleting an image used to remove the row and the result file on the spot. One +-- slip in the library and the result was gone — including what it had cost to +-- generate. Now a delete only sets `deleted_at`. The image disappears from every +-- view, every share link and every delivery, and comes back unchanged when it is +-- restored. Maintenance removes it for good after `settings.trash_days`. +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 = no trash: a delete is final, as before. +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 839f9ed..adc00b5 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -88,6 +88,7 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic 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, delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_metadata_sidecar, library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations, private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, @@ -337,6 +338,9 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
field('retention_days', e.target.value)} />
{t('Older images are removed automatically (daily). Empty = delete nothing.')}
+
+ field('trash_days', e.target.value)} /> +
{t('Deleted images wait this long in the trash and can be restored until then. 0 = no trash, a delete is final.')}
diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index 61793a7..d9e554f 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -30,8 +30,16 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D const [tagMenu, setTagMenu] = useState(null); const [share, setShare] = useState(null); const [tagFilter, setTagFilter] = useState(null); - const [toast, setToast] = useState(null); - const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2400); }; + const [toast, setToast] = useState<{ msg: string; undo?: () => void } | null>(null); + const toastTimer = React.useRef | null>(null); + // With an undo button the toast stays longer — long enough to notice the slip. + const notify = (msg: string, undo?: () => void) => { + if (toastTimer.current) clearTimeout(toastTimer.current); + setToast({ msg, undo }); + toastTimer.current = setTimeout(() => setToast(null), undo ? 7000 : 2400); + }; + const [trashView, setTrashView] = useState(false); + const [trash, setTrash] = useState<{ days: number; items: (Item & { deleted_at: string; days_left: number })[] }>({ days: 30, items: [] }); // Model name: the admin label, otherwise the readable tail of the model ID. const modelName = (id: string | null) => { @@ -42,12 +50,14 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D }; const load = useCallback(async () => { - const [i, f, m] = await Promise.all([ + const [i, f, m, tr] = 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 || []); + setTrash({ days: tr.days ?? 30, items: tr.items || [] }); }, []); useEffect(() => { load(); }, [load]); @@ -62,8 +72,30 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D 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(t('Deleted.')); }; + const trashAction = (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: [] })); + // Deleting moves into the trash; the toast offers to take it back right there. + const del = async (ids: string[]) => { + if (!ids.length) return; + setItems((x) => x.filter((y) => !ids.includes(y.id))); + const r = await trashAction('trash', ids); + setPick(new Set()); load(); + const n = (r.ids || []).length; + if (trash.days === 0) { notify(n === 1 ? t('Deleted.') : t('{n} deleted.', { n })); return; } + notify(n === 1 ? t('Moved to the trash.') : t('{n} moved to the trash.', { n }), + async () => { await trashAction('restore', r.ids || []); setToast(null); load(); }); + }; + const delSelected = () => del([...pick]); + const restore = async (ids: string[]) => { await trashAction('restore', ids); load(); notify(t('Restored.')); }; + const purge = async (ids: string[]) => { + if (!confirm(ids.length === 1 ? t('Delete this image for good? This cannot be undone.') : t('Delete {n} images for good? This cannot be undone.', { n: ids.length }))) return; + await trashAction('purge', ids); load(); notify(t('Deleted for good.')); + }; + const emptyTrash = async () => { + if (!confirm(t('Empty the trash? {n} images will be deleted for good.', { n: trash.items.length }))) return; + await fetch('/api/trash', { method: 'DELETE' }); load(); notify(t('Trash emptied.')); + }; const deliverSelected = async () => { notify(t('Sending to the delivery target …')); const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json()); @@ -131,9 +163,10 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D return (
- {t('Library')}{filtered.length ? ` · ${filtered.length}` : ''} + {trashView ? t('Trash · {n}', { n: trash.items.length }) + : <>{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}}
- {pick.size > 0 && <> + {pick.size > 0 && !trashView && <> {t('{n} selected', { n: pick.size })} @@ -145,10 +178,43 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D } - + {!trashView && } + {(trash.items.length > 0 || trashView) && ( + + )}
+ {trashView ? ( +
+
{trash.days > 0 + ? t('Deleted images stay here for {days} days and can be restored until then. They are hidden from the library, from every share link and from every delivery.', { days: trash.days }) + : t('The trash is switched off — deleting is final.')}
+ {trash.items.length === 0 ? ( +

{t('The trash is empty')}

+ ) : (<> +
+ + +
+
+ {trash.items.map((v) => ( +
+
+
{v.filename || '—'}
+
{v.days_left === 0 ? t('Removed at the next cleanup') : v.days_left === 1 ? t('1 day left') : t('{n} days left', { n: v.days_left })}
+
+ + +
+
+ ))} +
+ )} +
+ ) : (<> + {/* Folder filter */}
@@ -238,7 +304,7 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D {folders.map((o) => )} - +
); @@ -246,6 +312,8 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
)} + )} + {share && ( setShare(null)} locale={locale} dict={dict} /> )} @@ -286,7 +354,8 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
)} - {toast &&
{toast}
} + {toast &&
{toast.msg} + {toast.undo && }
} ); @@ -359,6 +428,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-head{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.danger{border-color:var(--err);color:var(--err);} + .tile.trashed .tile-image img{opacity:.6;filter:grayscale(.6);} + .name.static{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 f4a5d77..02d2e21 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -59,6 +59,8 @@ const ICONS: Record = { + + {title} diff --git a/src/lib/backup.ts b/src/lib/backup.ts index 1ee5d96..f0cd2e9 100644 --- a/src/lib/backup.ts +++ b/src/lib/backup.ts @@ -64,7 +64,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 4b9c482..65f2db2 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 /** Work through all deliveries of a job that are still open (pending). */ 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..7476b4d --- /dev/null +++ b/src/lib/itemaccess.ts @@ -0,0 +1,49 @@ +// Who may do what with an image. +// +// One rule, in one place. Before this file, only the file route checked access; +// renaming, deleting, retrying and zipping took any id from any signed-in user. +// On a single-user install that changes nothing. On a family install it meant +// one account could delete another account's images by id. +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; +} + +/** Reading: your own images, everything as admin, others' only in a shared library and never from a private session. */ +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; +} + +/** Changing or deleting: only your own images, or as admin. A shared library is for looking, not for tidying up other people's work. */ +export function canWrite(v: Viewer, it: Pick): boolean { + if (v.role === 'admin') return true; + return !!v.uid && it.created_by === v.uid; +} + +/** + * Loads an image together with what the access decision needs. + * Returns null both for "does not exist" and for "not yours" — a caller must not + * be able to tell the two apart by trying ids. + */ +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 190bdcc..8b2957f 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} old items removed.`); } + const { purgeExpiredTrash } = await import('./trash'); + const tr = await purgeExpiredTrash(); + if (tr) console.log(`[maintenance] Trash: ${tr} images removed for good.`); const p = await purgePrivate(60); if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`); const t = await purgeTracking(90); diff --git a/src/lib/printruns.ts b/src/lib/printruns.ts index 1cf0621..9a3cc3e 100644 --- a/src/lib/printruns.ts +++ b/src/lib/printruns.ts @@ -100,7 +100,7 @@ export async function recordRun(e: RunInput): 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] image references not stored:', err?.message || err)); } @@ -143,7 +143,7 @@ export async function listRuns(userId: string | null, isAdmin: boolean, limit = const images = 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 byRun = new Map(); diff --git a/src/lib/printsource.ts b/src/lib/printsource.ts index 63492b8..75c488a 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('Image not found.'); 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 63cbd7c..b218d20 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -61,9 +61,15 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; } /** Processes one item completely and updates its record. */ 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: 'Item not found' }; + // Moved to the trash while it was still waiting: do not pay a model for an + // image nobody wants any more. + 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 bb2cb80..b43842c 100644 --- a/src/lib/shares.ts +++ b/src/lib/shares.ts @@ -153,14 +153,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]); } /** Does this image belong to this link? Cross-check before every delivery. */ 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]); + // An image in the trash is not part of any link until it is restored. + `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; } @@ -233,7 +235,7 @@ export async function listShares(userId: string | null, isAdmin: boolean): Promi // share_views: with many accesses this page turned sluggish otherwise. `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 count, + WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL) AS count, s.visitor 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..545a1c9 --- /dev/null +++ b/src/lib/trash.ts @@ -0,0 +1,100 @@ +// The trash: delete softly, restore, empty, and let maintenance finish the job. +import { one, query } from './db.ts'; +import { deleteObject } from './storage.ts'; +import type { Viewer } from './itemaccess.ts'; + +export const DEFAULT_TRASH_DAYS = 30; + +/** Every stored object an image row points to. */ +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)]; +} + +/** + * Days until an image in the trash is removed for good. Rounded up, never below + * zero: "0 days" means the next maintenance run takes it. + */ +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 condition: rows this viewer may change. `$1` = uid, `$2` = is admin. */ +const OWN = `($2::boolean OR i.job_id IN (SELECT id FROM jobs WHERE created_by = $1))`; + +/** Moves images into the trash. Returns the ids that actually moved. */ +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); +} + +/** + * Removes images for good: the row and every file that no other image still + * uses. That last part matters — an alternative version shares its source file + * with the original, and deleting the original must not take the source of the + * alternative with it. + */ +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` is jsonb, not a Postgres array. A failure here must not stop + // the loop: the rows are already gone, and a skipped file is only wasted + // space, while an exception would skip every file after it. + 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('[trash] could not remove', k, (e as Error)?.message); } + } + } + return rows.map((r) => r.id); +} + +/** Empties the trash: the viewer's own images, or all of them for an admin. */ +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; +} + +/** Maintenance: everything that has been in the trash longer than the setting allows. */ +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/locales/de.json b/src/locales/de.json index fa7b670..98c6868 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -5,6 +5,7 @@ ".md on": ".md an", "0 = unlimited.": "0 = unbegrenzt.", "1 Sep 2026": "01.09.2026", + "1 day left": "Noch 1 Tag", "1 image": "1 Bild", "1 year": "1 Jahr", "1 year from now": "1 Jahr ab jetzt", @@ -218,6 +219,7 @@ "Assign images to this folder from the folder menu.": "Weise Bildern über das Ordner-Menü diesen Ordner zu.", "Automated tests for the geometry plus a raster sample of the finished PDFs at 254 dpi (= 10 px/mm) with edge measurement.": "Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.", "Automation / MCP access": "Automatisierung / MCP-Zugriff", + "Back to the library": "Zurück zur Bibliothek", "Back up all existing images to the mirror": "Alle vorhandenen Bilder auf den Sicherungsspiegel sichern", "Back up all finished images to every backup target (mirror + marked targets)?": "Alle fertigen Bilder auf alle Backup-Ziele (Sicherungsspiegel + markierte Ziele) sichern?", "Back up all finished images to the mirror?": "Alle fertigen Bilder auf den Sicherungsspiegel sichern?", @@ -349,6 +351,7 @@ "Delete": "Löschen", "Delete folder": "Ordner löschen", "Delete folder? The images are kept.": "Ordner löschen? Die Bilder bleiben erhalten.", + "Delete for good": "Endgültig löschen", "Delete items older than {days} days now?": "Positionen älter als {days} Tage jetzt löschen?", "Delete preset": "Vorlage löschen", "Delete preset?": "Vorlage löschen?", @@ -357,7 +360,12 @@ "Delete target?": "Ziel löschen?", "Delete the link \"{name}\" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.": "Den Link „{name}\" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. Nur abschalten reicht meistens.", "Delete the source images of all finished items? Results are kept.": "Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.", + "Delete this image for good? This cannot be undone.": "Dieses Bild endgültig löschen? Das lässt sich nicht rückgängig machen.", "Delete this job?": "Diesen Auftrag löschen?", + "Delete {n} images for good? This cannot be undone.": "{n} Bilder endgültig löschen? Das lässt sich nicht rückgängig machen.", + "Deleted for good.": "Endgültig gelöscht.", + "Deleted images stay here for {days} days and can be restored until then. They are hidden from the library, from every share link and from every delivery.": "Gelöschte Bilder bleiben {days} Tage hier und lassen sich bis dahin wiederherstellen. In der Bibliothek, in Freigabelinks und bei der Auslieferung tauchen sie nicht mehr auf.", + "Deleted images wait this long in the trash and can be restored until then. 0 = no trash, a delete is final.": "So lange bleiben gelöschte Bilder im Papierkorb und lassen sich bis dahin wiederherstellen. 0 = kein Papierkorb, Löschen ist endgültig.", "Deleted.": "Gelöscht.", "Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.": "Löscht alle Bilder, Aufträge und Ordner unwiderruflich, um sauber neu zu starten. Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder auf den Sicherungsspiegel sichern\".", "Deliver": "Ausliefern", @@ -388,6 +396,8 @@ "Email address and notifications.": "E-Mail-Adresse und Benachrichtigungen.", "Empty = everyone with the link gets in": "Leer = jeder mit dem Link kommt rein", "Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.": "Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.", + "Empty the trash": "Papierkorb leeren", + "Empty the trash? {n} images will be deleted for good.": "Papierkorb leeren? {n} Bilder werden endgültig gelöscht.", "Enable the backup mirror and save first.": "Sicherungsspiegel erst aktivieren & speichern.", "Enter a host and save first.": "Erst Host eintragen und speichern.", "Enter a new key to replace it": "Neuen Key eingeben zum Wechseln", @@ -523,7 +533,9 @@ "Monthly budget ($)": "Monatliches Budget ($)", "More output targets (FTP/SFTP)": "Weitere Ausgabeziele (FTP/SFTP)", "More settings": "Mehr einstellen", + "Move to the trash": "In den Papierkorb", "Moved into the folder.": "In Ordner verschoben.", + "Moved to the trash.": "In den Papierkorb gelegt.", "My account": "Mein Konto", "My account · Klarbild": "Mein Konto · Klarbild", "My account, sharing & email": "Mein Konto, Teilen & E-Mail", @@ -689,6 +701,7 @@ "Remove frame, wall and shop interface.": "Rahmen, Wand und Shop-Oberfläche entfernen.", "Remove {images}?": "{images} entfernen?", "Remove {name}": "{name} entfernen", + "Removed at the next cleanup": "Wird beim nächsten Aufräumen entfernt", "Removed from the folder.": "Aus Ordner entfernt.", "Rename folder": "Ordner umbenennen", "Rename folder:": "Ordner umbenennen:", @@ -699,6 +712,9 @@ "Reset library & jobs": "Bibliothek & Aufträge zurücksetzen", "Resetting …": "Setze zurück …", "Resolution (dpi)": "Auflösung (dpi)", + "Restore": "Wiederherstellen", + "Restore all": "Alle wiederherstellen", + "Restored.": "Wiederhergestellt.", "Results land under base folder / YYYY-MM /. Credentials are stored encrypted.": "Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.", "Resume": "Fortsetzen", "Retention (days)": "Aufbewahrung (Tage)", @@ -834,6 +850,8 @@ "The problem": "Das Problem", "The same image once more — for a second format, say": "Dasselbe Bild noch einmal — z. B. für ein zweites Format", "The second way out for large sizes: the image is spread across several sheets. The tiles overlap by a glue flap, which is printed on both neighbouring sheets and marked with a dashed line. Every sheet carries its number with row and column.": "Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.", + "The trash is empty": "Der Papierkorb ist leer", + "The trash is switched off — deleting is final.": "Der Papierkorb ist abgeschaltet — Löschen ist endgültig.", "The two entries do not match.": "Die beiden Eingaben stimmen nicht überein.", "The usual route runs through a layout program or through the printer driver, and both of them mean too well: “fit to page”, “scale to fit”, “scale borderless”. What comes out is a print that is almost right. For a passport photo, “almost” is too little, and using a whole A4 sheet for a 4 × 3 snapshot, only because it would otherwise not fit on the page at 100% size, is plain waste.": "Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein 4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in 100 % Größe auf die Seite passt, ist schlicht Verschwendung.", "The whole image stays visible, with a border around it": "Ganzes Bild bleibt sichtbar, außen entsteht ein Rand", @@ -855,12 +873,16 @@ "Today ${today} · 7 days ${week}": "Heute ${today} · 7 Tage ${week}", "Too large": "Zu groß", "Too many attempts. Please try again in a quarter of an hour.": "Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.", + "Trash (days)": "Papierkorb (Tage)", + "Trash emptied.": "Papierkorb geleert.", + "Trash · {n}": "Papierkorb · {n}", "True to size": "Maßhaltig", "Try again": "Noch einmal versuchen", "US visa 51 × 51 mm (2 × 2 in)": "Visum USA 51 × 51 mm (2 × 2 in)", "Under “File names” the existing library can be brought onto the unique naming scheme. “Check library” first shows what would change and how many names are currently used twice; “Rename now” carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. “Deliver again” then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.": "Unter „Dateinamen\" lässt sich die vorhandene Bibliothek auf das eindeutige Namensschema bringen. „Bibliothek prüfen\" zeigt zuerst, was sich ändern würde und wie viele Namen aktuell doppelt vergeben sind; „Jetzt umbenennen\" setzt es um. Die Bilddateien selbst bleiben dabei unangetastet — sie liegen im Speicher unter ihrer Kennung, nicht unter ihrem Namen. Ein zweiter Lauf ändert nichts mehr. „Erneut ausliefern\" lädt danach alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch; die alten Dateien bleiben am Ziel liegen und räumst du dort selbst weg.", "Under the hood": "Unter der Haube", "Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.": "Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose für den Spiegel.", + "Undo": "Rückgängig", "Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr", "Unit": "Einheit", "Unlink": "Trennen", @@ -1015,6 +1037,8 @@ "{images} on this sheet": "{images} auf diesem Bogen", "{n} copies to backup targets, {failed} errors.": "{n} Kopien auf Backup-Ziele, {failed} Fehler.", "{n} copies to backup targets.": "{n} Kopien auf Backup-Ziele.", + "{n} days left": "Noch {n} Tage", + "{n} deleted.": "{n} gelöscht.", "{n} entries in {path}": "{n} Einträge in {path}", "{n} extra sheet": "{n} Extrabogen", "{n} extra sheets": "{n} Extrabogen", @@ -1029,6 +1053,7 @@ "{n} images renamed, {ok} were already fine.": "{n} Bilder umbenannt, {ok} waren schon in Ordnung.", "{n} leftovers removed from “{folder}”.": "{n} Reste in „{folder}\" entfernt.", "{n} min ago": "vor {n} min", + "{n} moved to the trash.": "{n} in den Papierkorb gelegt.", "{n} names were used twice": "{n} Namen waren doppelt vergeben", "{n} of {checked} images would get a new name — {dupes} names are currently used twice.": "{n} von {checked} Bildern bekämen einen neuen Namen — {dupes} Namen sind aktuell doppelt vergeben.", "{n} of {checked} images would get a new name.": "{n} von {checked} Bildern bekämen einen neuen Namen.", diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index 22abd0f..f85fd45 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_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null, // Sharing share_default_days: s.share_default_days, + trash_days: s.trash_days, } }); }; @@ -64,9 +65,16 @@ export const PATCH: APIRoute = async ({ request }) => { 'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext', 'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw', 'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_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') { + // NOT NULL in the database: empty means the default, anything else a whole number of days. + const n = raw === '' || raw == null ? 30 : Math.round(Number(raw)); + if (!Number.isFinite(n) || n < 0 || n > 3650) return json({ error: 'Trash: 0 to 3650 days.' }, 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 84cce0e..0442207 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 da4bf5d..48b0b70 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: 'Not found.' }, 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 }) => { +/** + * Moves the image into the trash. `?purge=1` removes an image that is already in + * the trash for good. With the trash switched off (trash_days = 0) a delete is + * final straight away, as it used to be. + */ +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: 'Not found.' }, 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 a750e73..fae57cd 100644 --- a/src/pages/api/items/[id]/alternative.ts +++ b/src/pages/api/items/[id]/alternative.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { one, query } from '../../../../lib/db'; import { enqueue } from '../../../../lib/queue'; import { has, moduleOff } from '../../../../lib/modules'; +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) => export const POST: APIRoute = async ({ params, locals }) => { if (!has('ai')) return moduleOff('ai'); if (!locals.user) return new Response('Unauthorized', { status: 401 }); + if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Not found.' }, 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: 'Not found.' }, 404); diff --git a/src/pages/api/items/[id]/retry.ts b/src/pages/api/items/[id]/retry.ts index 5176097..60e8eb0 100644 --- a/src/pages/api/items/[id]/retry.ts +++ b/src/pages/api/items/[id]/retry.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { one, query } from '../../../../lib/db'; import { enqueue } from '../../../../lib/queue'; import { has, moduleOff } from '../../../../lib/modules'; +import { loadItemFor } from '../../../../lib/itemaccess'; export const prerender = false; const json = (b: unknown, s = 200) => @@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) => export const POST: APIRoute = async ({ params, locals }) => { if (!has('ai')) return moduleOff('ai'); if (!locals.user) return new Response('Unauthorized', { status: 401 }); + if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Not found.' }, 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: 'Not found.' }, 404); diff --git a/src/pages/api/items/[id]/reuse.ts b/src/pages/api/items/[id]/reuse.ts index ece0483..871c11b 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) => * the studio as a starting point. */ 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: 'There is no result.' }, 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: 'There is no result.' }, 404); diff --git a/src/pages/api/items/index.ts b/src/pages/api/items/index.ts index b7393ca..5c199ad 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 jobs never show up in the library; visibility follows the setting. - 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 4da9247..0790eb2 100644 --- a/src/pages/api/items/zip.ts +++ b/src/pages/api/items/zip.ts @@ -13,8 +13,14 @@ export const POST: APIRoute = async ({ request, locals }) => { const ids: string[] = b.itemIds || []; if (!ids.length) return new Response('Nothing selected.', { status: 400 }); + // Only images this account may see, and none from the trash. Before, any id + // from any account went into the archive. 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]); + `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('No results.', { 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 6667011..649e44c 100644 --- a/src/pages/api/jobs/[id]/[action].ts +++ b/src/pages/api/jobs/[id]/[action].ts @@ -31,7 +31,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 226bc6b..b5270ec 100644 --- a/src/pages/api/s/[slug]/zip.ts +++ b/src/pages/api/s/[slug]/zip.ts @@ -62,7 +62,7 @@ export const GET: APIRoute = async ({ params, request, clientAddress }) => { const paths = 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`, [images.map((b) => b.id)]); const byId = new Map(paths.map((p) => [p.id, p])); diff --git a/src/pages/api/shares/index.ts b/src/pages/api/shares/index.ts index 98d0afd..01dec79 100644 --- a/src/pages/api/shares/index.ts +++ b/src/pages/api/shares/index.ts @@ -37,15 +37,15 @@ export const POST: APIRoute = async ({ request, locals }) => { const isAdmin = locals.user.role === 'admin'; // One query instead of one per image: with 500 images that was 500 round trips. - const found = 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 found = 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 (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404); for (const it of found) { - if (!it.result_path || it.status !== 'done') return json({ error: 'One of the images is no longer available.' }, 404); + if (!it.result_path || it.status !== 'done' || it.deleted_at) return json({ error: 'One of the images is no longer available.' }, 404); // Private jobs are deliberately never shared — the whole point is that they // leave no trace. if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403); diff --git a/src/pages/api/trash.ts b/src/pages/api/trash.ts new file mode 100644 index 0000000..97e7f0c --- /dev/null +++ b/src/pages/api/trash.ts @@ -0,0 +1,42 @@ +import type { APIRoute } from 'astro'; +import { query } from '../../lib/db'; +import { has, moduleOff } from '../../lib/modules'; +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; + +/** What is in the trash — your own images, or all of them for an admin. */ +export const GET: APIRoute = async ({ locals }) => { + if (!has('ai')) return moduleOff('ai'); + 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: [...] } — several at once, one round trip. */ +export const POST: APIRoute = async ({ request, locals }) => { + if (!has('ai')) return moduleOff('ai'); + 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: 'No images selected.' }, 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: 'Unknown action.' }, 400); +}; + +/** Empties the trash. */ +export const DELETE: APIRoute = async ({ locals }) => { + if (!has('ai')) return moduleOff('ai'); + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + return json({ ok: true, purged: await emptyTrash(locals.user) }); +}; diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index a79aa25..5409254 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -68,6 +68,10 @@ world such a module does not exist. - GET /api/jobs — the most recent jobs. GET /api/jobs/:id — job plus items (incl. error_message, finished_at). - POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}. - POST /api/items/:id/{retry|reuse|alternative}. +- DELETE /api/items/:id — moves the image into the trash (restorable for settings.trash_days, default 30). + ?purge=1 removes an image that is already in the trash for good. +- GET /api/trash — the trash, with days_left per image. POST /api/trash { action: trash|restore|purge, ids[] }. + DELETE /api/trash — empty it. Only your own images (admins: all). - GET /api/items/:id/file — the result image. Query: ?thumb=1 (preview), ?preview=1 (small JPG for full screen), ?download=1 (as a file), ?src=1 (the source). The content type is taken from the magic bytes (PNG/JPG/WEBP). 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); +});