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).
42 lines
2.2 KiB
TypeScript
42 lines
2.2 KiB
TypeScript
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);
|
|
});
|