diff --git a/CHANGELOG.md b/CHANGELOG.md index bced11c..6dd2014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to Klarbild are documented here. Newest first. +## 2026-10-10 — Trash, access rules, current dependencies + +### Added +- **Trash instead of deleting for good.** Deleting moves an image into the trash + (`items.deleted_at`, migration 017). It disappears from the library, share links, ZIPs, + deliveries, the backup mirror, print sources and the print history, and returns + unchanged on restore. A toast offers "Rückgängig"; the trash view lists days left, + restore, delete for good, empty. Maintenance purges after `settings.trash_days` + (default 30, Admin → Speicher); 0 switches the trash off. API: `DELETE /api/items/:id` + (trash), `?purge=1`, `GET|POST|DELETE /api/trash`. +- Purging deletes only files no other image still uses — alternatives share their source + with the original, including combine sources in `source_paths` (jsonb). +- An image trashed while queued is skipped by the worker instead of being generated. +- Favicon and iPhone home-screen icon. + +### Fixed +- **Access checks on every image route.** Only the file route checked visibility before; + rename, delete, retry, alternative, reuse and the selection ZIP accepted any id from any + account. One rule in `src/lib/itemaccess.ts`: read own (or shared library, never a + private session), change own; admins all. +- Telegram print sheets had no footer: the footer was passed as a string instead of a + `FooterSpec`, so `.text` was undefined. +- `npm run typecheck` exists and reports 0 errors (17 before). + +### Changed +- Dependencies: nodemailer 10, basic-ftp 6, argon2 0.45, archiver 8 (`ZipArchive`), + ssh2-sftp-client 12, @astrojs/react 7, Astro 7.3.8, sharp 0.35.5. `npm audit`: 0. + pg-boss stays on 10 (12 cannot migrate the v10 schema in place). +- basic-ftp 6 no longer redirects the data connection when an FTP server behind NAT + announces its private address. Run "Verbindung testen" for each FTPS target. + ## 2026-09-01 (later) — Unique names for print files ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d7d0511..9c57269 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Arbeitsanweisung für den bauenden Agenten. Führend: `01-anforderungen.md` (Was `Imagetool-Lea/` (00,01,03,06,07; 02/04/05 stehen noch aus). ## Stack (fest) -Astro 5 (`@astrojs/node`, `output: server`) + React-Inseln · Postgres · S3 (MinIO) · +Astro 7 (`@astrojs/node` 11, `output: server`) + React-Inseln · Postgres · S3 (MinIO) · pg-boss (Worker im selben Prozess, `worker.ts`) · sharp · grammY · argon2 · AES-256-GCM. Kein Tailwind. OKLCH-Tokens in `src/styles/tokens.css`. Gitea → Coolify → Hetzner. diff --git a/migrations/017_papierkorb.sql b/migrations/017_papierkorb.sql new file mode 100644 index 0000000..8ff3d44 --- /dev/null +++ b/migrations/017_papierkorb.sql @@ -0,0 +1,13 @@ +-- Papierkorb statt endgültigem Löschen --------------------------------------- +-- +-- Löschen in der Bibliothek hat bisher Zeile und Ergebnisdatei sofort entfernt. +-- Ein Fehlklick, und das Ergebnis war weg — samt dem, was die Erzeugung gekostet +-- hat. Jetzt setzt Löschen nur `deleted_at`. Das Bild verschwindet aus jeder +-- Ansicht, jedem Freigabelink und jeder Auslieferung und kommt beim +-- Wiederherstellen unverändert zurück. Die Wartung entfernt es nach +-- `settings.trash_days` endgültig. +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 = kein Papierkorb: Löschen ist sofort endgültig, wie früher. +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 23ef501..0367369 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -82,6 +82,7 @@ export default function AdminApp() { 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, picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar, library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations, private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, @@ -317,6 +318,9 @@ export default function AdminApp() {
Dateinamen sind eindeutig. Jedes Ergebnis heißt
Was in Klarbild neu ist. Neueste Änderungen oben.JJJJ-MM-TT_HHMMSS_motiv_format_kürzel, also z. B.
diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts
index 21d057b..9dcef6d 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_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
// Teilen
share_default_days: s.share_default_days,
+ trash_days: s.trash_days,
} });
};
@@ -64,9 +65,16 @@ export const PATCH: APIRoute = async ({ request }) => {
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_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') {
+ // In der Datenbank NOT NULL: leer heißt Standard, sonst ganze Tage.
+ const n = raw === '' || raw == null ? 30 : Math.round(Number(raw));
+ if (!Number.isFinite(n) || n < 0 || n > 3650) return json({ error: 'Papierkorb: 0 bis 3650 Tage.' }, 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 e03ffdb..90a4789 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 fe7ba38..80c156b 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: 'Nicht gefunden.' }, 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 }) => {
+/**
+ * Legt das Bild in den Papierkorb. `?purge=1` entfernt ein Bild, das schon im
+ * Papierkorb liegt, endgültig. Ist der Papierkorb abgeschaltet (trash_days = 0),
+ * ist Löschen sofort endgültig, wie früher.
+ */
+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: 'Nicht gefunden.' }, 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 b7c90f2..f8480e4 100644
--- a/src/pages/api/items/[id]/alternative.ts
+++ b/src/pages/api/items/[id]/alternative.ts
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
+import { loadItemFor } from '../../../../lib/itemaccess';
export const prerender = false;
const json = (b: unknown, s = 200) =>
@@ -9,6 +10,7 @@ const json = (b: unknown, s = 200) =>
/** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
+ if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Nicht gefunden.' }, 404);
const item = await oneEin Papierkorb, und ein Symbol fürs iPhone
+
+ Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast
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);
+});