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

+9 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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` +
+1 -1
View File
@@ -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 });
};
+20 -8
View File
@@ -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 });
};
+2
View File
@@ -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<any>(
`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);
+2
View File
@@ -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);
+2
View File
@@ -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);
+1 -1
View File
@@ -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);
+7 -1
View File
@@ -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 } });
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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]));
+3 -3
View File
@@ -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);
+42
View File
@@ -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<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: [...] } — 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) });
};