Trash instead of deleting for good — and access checks on every image route

Deleting an image in the library removed the row and the result file on
the spot. One slip and a result was gone, including what it had cost to
generate. Now a delete moves the image into the trash:

- It disappears from the library, from every share link, from ZIPs, from
  deliveries, the backup mirror, print sources and the print history —
  and comes back unchanged when restored.
- The toast offers "Undo" for seven seconds. A trash view lists what is
  in there with the days left, restore, delete for good, empty.
- Maintenance removes images for good after `settings.trash_days`
  (default 30, set in Admin -> Storage). 0 switches the trash off.
- An image trashed while it is still queued is skipped by the worker
  instead of being generated — and billed — for nobody.
- Deleting for good removes only files no other image still uses. An
  alternative version shares its source with the original; before, the
  retention cleanup could take the source of the other one with it. This
  includes combine sources, which live in jsonb, not in an array.

While going through every query that touches images, it turned out that
only the file route checked who may see an image. Renaming, deleting,
retrying, generating an alternative, reusing and zipping took any id from
any signed-in account. On a single-user install that changes nothing; on
a family install one account could delete another's images. One rule now
lives in src/lib/itemaccess.ts: read your own (or everything in a shared
library, never a private session), change only your own; admins all.
A refused id answers 404 like a missing one.

Also: a favicon and an iPhone home-screen icon. There was none, so the
tab showed a blank page and /favicon.ico bounced to the login.

Verified against a real Postgres with two accounts and real files on
disk: 35 end-to-end checks (access, share links, restore, purge with
shared sources, expiry through maintenance, trash switched off). In the
browser, English and German: delete, undo, trash view, restore, no
horizontal scroll at 390 px. Unit tests: 130 (4 new).
This commit is contained in:
till committed 2026-10-10 06:24:30 +00:00
1 parent ec987dfc82
commit 1bcc2edeb3
31 files changed
+435 -39

No files matched your search

+100
View File
@@ -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<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 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<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);
}
/**
* 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<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` 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<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;
}
/** Maintenance: everything that has been in the trash longer than the setting allows. */
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;
}