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:
31 files changed
+435
-39
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,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,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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
};
|
||||
Reference in new issue
Block a user