Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28a6049548 | ||
|
|
ee887cc29c |
No files matched your search
@@ -2,6 +2,37 @@
|
|||||||
|
|
||||||
All notable changes to Klarbild are documented here. Newest first.
|
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
|
## 2026-09-01 (later) — Unique names for print files
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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).
|
`Imagetool-Lea/` (00,01,03,06,07; 02/04/05 stehen noch aus).
|
||||||
|
|
||||||
## Stack (fest)
|
## 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.
|
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.
|
Kein Tailwind. OKLCH-Tokens in `src/styles/tokens.css`. Gitea → Coolify → Hetzner.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
Generated
+865
-1788
File diff suppressed because it is too large.
Load diff
+24
-20
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.6"
|
"node": ">=22.12"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
@@ -12,34 +12,38 @@
|
|||||||
"start": "node ./dist/server/entry.mjs",
|
"start": "node ./dist/server/entry.mjs",
|
||||||
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.1.5",
|
"@astrojs/node": "^11.1.7",
|
||||||
"@astrojs/react": "^6.0.5",
|
"@astrojs/react": "^7.0.1",
|
||||||
"@aws-sdk/client-s3": "^3.700.0",
|
"@aws-sdk/client-s3": "^3.1149.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
"@aws-sdk/s3-request-presigner": "^3.1149.0",
|
||||||
"@fontsource/space-grotesk": "^5.1.0",
|
"@fontsource/space-grotesk": "^5.1.0",
|
||||||
"@fontsource/space-mono": "^5.1.0",
|
"@fontsource/space-mono": "^5.1.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^8.0.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.45.1",
|
||||||
"astro": "^7.3.2",
|
"astro": "^7.3.8",
|
||||||
"basic-ftp": "^5.0.5",
|
"basic-ftp": "^6.2.3",
|
||||||
"grammy": "^1.30.0",
|
"grammy": "^1.46.0",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"nodemailer": "^9.1.0",
|
"nodemailer": "^10.0.16",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.23.1",
|
||||||
"pg-boss": "^10.1.5",
|
"pg-boss": "^10.1.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.3.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"ssh2-sftp-client": "^11.0.0"
|
"ssh2-sftp-client": "^12.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/pg": "^8.11.10",
|
"@types/archiver": "^8.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/node": "^26.6.5",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/pg": "^8.23.1",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/ssh2-sftp-client": "^9.0.6",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#F6F6F1"/><circle cx="16" cy="16" r="8.5" fill="none" stroke="#1B3BE0" stroke-width="2.2"/><path d="M16 3.5v25M3.5 16h25" stroke="#1B3BE0" stroke-width="2.2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
@@ -82,6 +82,7 @@ export default function AdminApp() {
|
|||||||
output_ext: s.output_ext || 'png',
|
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,
|
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,
|
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,
|
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,
|
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||||
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||||
@@ -317,6 +318,9 @@ export default function AdminApp() {
|
|||||||
<div className="feld"><label>Aufbewahrung (Tage)</label>
|
<div className="feld"><label>Aufbewahrung (Tage)</label>
|
||||||
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||||
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
|
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
|
||||||
|
<div className="feld"><label>Papierkorb (Tage)</label>
|
||||||
|
<input className="input" type="number" min={0} max={3650} value={s.trash_days ?? 30} onChange={(e) => field('trash_days', e.target.value)} />
|
||||||
|
<div className="fein">So lange bleiben gelöschte Bilder im Papierkorb und lassen sich bis dahin wiederherstellen. 0 = kein Papierkorb, Löschen ist endgültig.</div></div>
|
||||||
<div className="reihe">
|
<div className="reihe">
|
||||||
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
|
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
|
||||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
|
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
|
||||||
|
|||||||
@@ -28,8 +28,16 @@ export default function LibraryApp() {
|
|||||||
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||||
const [teilen, setTeilen] = useState<ShareZiel | null>(null);
|
const [teilen, setTeilen] = useState<ShareZiel | null>(null);
|
||||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<{ msg: string; undo?: () => void } | null>(null);
|
||||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
|
const toastTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// Mit Rückgängig-Knopf bleibt der Hinweis länger stehen — lang genug, um den Fehlklick zu bemerken.
|
||||||
|
const notify = (msg: string, undo?: () => void) => {
|
||||||
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
|
setToast({ msg, undo });
|
||||||
|
toastTimer.current = setTimeout(() => setToast(null), undo ? 7000 : 2400);
|
||||||
|
};
|
||||||
|
const [korbAnsicht, setKorbAnsicht] = useState(false);
|
||||||
|
const [korb, setKorb] = useState<{ days: number; items: (Item & { deleted_at: string; days_left: number })[] }>({ days: 30, items: [] });
|
||||||
|
|
||||||
// Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
|
// Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
|
||||||
const modelName = (id: string | null) => {
|
const modelName = (id: string | null) => {
|
||||||
@@ -40,12 +48,14 @@ export default function LibraryApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [i, f, m] = await Promise.all([
|
const [i, f, m, k] = await Promise.all([
|
||||||
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
||||||
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
||||||
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
||||||
|
fetch('/api/trash').then((r) => r.json()).catch(() => ({ days: 30, items: [] })),
|
||||||
]);
|
]);
|
||||||
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
||||||
|
setKorb({ days: k.days ?? 30, items: k.items || [] });
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@@ -60,8 +70,30 @@ export default function LibraryApp() {
|
|||||||
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
|
const korbAktion = (action: 'trash' | 'restore' | 'purge', ids: string[]) =>
|
||||||
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); };
|
fetch('/api/trash', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ids }) })
|
||||||
|
.then((r) => r.json()).catch(() => ({ ids: [] }));
|
||||||
|
// Löschen legt in den Papierkorb; der Hinweis bietet direkt an, es zurückzunehmen.
|
||||||
|
const del = async (ids: string[]) => {
|
||||||
|
if (!ids.length) return;
|
||||||
|
setItems((x) => x.filter((y) => !ids.includes(y.id)));
|
||||||
|
const r = await korbAktion('trash', ids);
|
||||||
|
setPick(new Set()); load();
|
||||||
|
const n = (r.ids || []).length;
|
||||||
|
if (korb.days === 0) { notify(n === 1 ? 'Gelöscht.' : `${n} gelöscht.`); return; }
|
||||||
|
notify(n === 1 ? 'In den Papierkorb gelegt.' : `${n} in den Papierkorb gelegt.`,
|
||||||
|
async () => { await korbAktion('restore', r.ids || []); setToast(null); load(); });
|
||||||
|
};
|
||||||
|
const delSelected = () => del([...pick]);
|
||||||
|
const wiederherstellen = async (ids: string[]) => { await korbAktion('restore', ids); load(); notify('Wiederhergestellt.'); };
|
||||||
|
const endgueltig = async (ids: string[]) => {
|
||||||
|
if (!confirm(ids.length === 1 ? 'Dieses Bild endgültig löschen? Das lässt sich nicht rückgängig machen.' : `${ids.length} Bilder endgültig löschen? Das lässt sich nicht rückgängig machen.`)) return;
|
||||||
|
await korbAktion('purge', ids); load(); notify('Endgültig gelöscht.');
|
||||||
|
};
|
||||||
|
const korbLeeren = async () => {
|
||||||
|
if (!confirm(`Papierkorb leeren? ${korb.items.length} Bilder werden endgültig gelöscht.`)) return;
|
||||||
|
await fetch('/api/trash', { method: 'DELETE' }); load(); notify('Papierkorb geleert.');
|
||||||
|
};
|
||||||
const deliverSelected = async () => {
|
const deliverSelected = async () => {
|
||||||
notify('Sende an Picdrop …');
|
notify('Sende an Picdrop …');
|
||||||
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
||||||
@@ -129,9 +161,9 @@ export default function LibraryApp() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="kopfzeile">
|
<div className="kopfzeile">
|
||||||
<span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
<span className="mono-label">{korbAnsicht ? `Papierkorb · ${korb.items.length}` : <>Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</>}</span>
|
||||||
<div className="reihe">
|
<div className="reihe">
|
||||||
{pick.size > 0 && <>
|
{pick.size > 0 && !korbAnsicht && <>
|
||||||
<span className="fein">{pick.size} gewählt</span>
|
<span className="fein">{pick.size} gewählt</span>
|
||||||
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
|
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
|
||||||
<button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button>
|
<button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button>
|
||||||
@@ -143,10 +175,43 @@ export default function LibraryApp() {
|
|||||||
</select>
|
</select>
|
||||||
<button className="mini" onClick={delSelected}>Löschen</button>
|
<button className="mini" onClick={delSelected}>Löschen</button>
|
||||||
</>}
|
</>}
|
||||||
<button className="mini" onClick={newFolder}>Neuer Ordner</button>
|
{!korbAnsicht && <button className="mini" onClick={newFolder}>Neuer Ordner</button>}
|
||||||
|
{(korb.items.length > 0 || korbAnsicht) && (
|
||||||
|
<button className={`mini ${korbAnsicht ? 'stark' : ''}`} onClick={() => setKorbAnsicht(!korbAnsicht)}>
|
||||||
|
{korbAnsicht ? 'Zurück zur Bibliothek' : `Papierkorb · ${korb.items.length}`}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{korbAnsicht ? (
|
||||||
|
<div className="korb">
|
||||||
|
<div className="fein hinweis">{korb.days > 0
|
||||||
|
? `Gelöschte Bilder bleiben ${korb.days} Tage hier und lassen sich bis dahin wiederherstellen. In der Bibliothek, in Freigabelinks und bei der Auslieferung tauchen sie nicht mehr auf.`
|
||||||
|
: 'Der Papierkorb ist abgeschaltet — Löschen ist endgültig.'}</div>
|
||||||
|
{korb.items.length === 0 ? (
|
||||||
|
<div className="leer"><span className="regmark" /><h3>Der Papierkorb ist leer</h3></div>
|
||||||
|
) : (<>
|
||||||
|
<div className="reihe" style={{ marginBottom: 12 }}>
|
||||||
|
<button className="mini" onClick={() => wiederherstellen(korb.items.map((x) => x.id))}>Alle wiederherstellen</button>
|
||||||
|
<button className="mini gefahr" onClick={korbLeeren}>Papierkorb leeren</button>
|
||||||
|
</div>
|
||||||
|
<div className="galerie">
|
||||||
|
{korb.items.map((v) => (
|
||||||
|
<article key={v.id} className="kachel geloescht">
|
||||||
|
<div className="kachel-bild"><img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" /></div>
|
||||||
|
<div className="name fest">{v.filename || '—'}</div>
|
||||||
|
<div className="meta">{v.days_left === 0 ? 'Wird beim nächsten Aufräumen entfernt' : v.days_left === 1 ? 'Noch 1 Tag' : `Noch ${v.days_left} Tage`}</div>
|
||||||
|
<div className="reihe knapp">
|
||||||
|
<button className="mini stark2" onClick={() => wiederherstellen([v.id])}>Wiederherstellen</button>
|
||||||
|
<button className="mini" onClick={() => endgueltig([v.id])}>Endgültig löschen</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
|
|
||||||
{/* Ordner-Filter */}
|
{/* Ordner-Filter */}
|
||||||
<div className="ordnerleiste">
|
<div className="ordnerleiste">
|
||||||
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
|
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
|
||||||
@@ -236,7 +301,7 @@ export default function LibraryApp() {
|
|||||||
<option value="">Kein Ordner</option>
|
<option value="">Kein Ordner</option>
|
||||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
<button className="mini" onClick={() => del([v.id])} title="In den Papierkorb">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -244,6 +309,8 @@ export default function LibraryApp() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
{teilen && (
|
{teilen && (
|
||||||
<ShareDialog ziel={teilen} onClose={() => setTeilen(null)} />
|
<ShareDialog ziel={teilen} onClose={() => setTeilen(null)} />
|
||||||
)}
|
)}
|
||||||
@@ -282,7 +349,8 @@ export default function LibraryApp() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast" role="status">{toast.msg}
|
||||||
|
{toast.undo && <button className="toast-undo" onClick={toast.undo}>Rückgängig</button>}</div>}
|
||||||
<LibStyles />
|
<LibStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -355,6 +423,10 @@ function LibStyles() {
|
|||||||
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
||||||
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
||||||
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
||||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;display:flex;align-items:center;gap:14px;}
|
||||||
|
.toast-undo{background:none;border:1px solid rgba(251,251,247,.5);color:#FBFBF7;border-radius:3px;padding:4px 10px;font-family:inherit;font-size:12.5px;cursor:pointer;}
|
||||||
|
.mini.gefahr{border-color:var(--err);color:var(--err);}
|
||||||
|
.kachel.geloescht .kachel-bild img{opacity:.6;filter:grayscale(.6);}
|
||||||
|
.name.fest{font-family:var(--font-mono);font-size:12px;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
`}</style>;
|
`}</style>;
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,8 @@ const ICONS: Record<string, string> = {
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#EAEAE3" />
|
<meta name="theme-color" content="#EAEAE3" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ export async function backupAll(): Promise<{ items: number; mirrored: number; fa
|
|||||||
const dests = await backupDestinations();
|
const dests = await backupDestinations();
|
||||||
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
||||||
const rows = await query<{ id: string }>(
|
const rows = await query<{ id: string }>(
|
||||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
|
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL AND deleted_at IS NULL ORDER BY created_at`);
|
||||||
let mirrored = 0, failed = 0;
|
let mirrored = 0, failed = 0;
|
||||||
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
||||||
return { items: rows.length, mirrored, failed };
|
return { items: rows.length, mirrored, failed };
|
||||||
|
|||||||
+1
-1
@@ -80,6 +80,6 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag
|
|||||||
/** Alle noch offenen (pending) Auslieferungen eines Auftrags abarbeiten. */
|
/** Alle noch offenen (pending) Auslieferungen eines Auftrags abarbeiten. */
|
||||||
export async function deliverPendingForJob(jobId: string): Promise<void> {
|
export async function deliverPendingForJob(jobId: string): Promise<void> {
|
||||||
const items = await query<{ id: string }>(
|
const items = await query<{ id: string }>(
|
||||||
`SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done'`, [jobId]);
|
`SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done' AND deleted_at IS NULL`, [jobId]);
|
||||||
for (const it of items) await deliverItem(it.id);
|
for (const it of items) await deliverItem(it.id);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Wer darf was mit einem Bild?
|
||||||
|
//
|
||||||
|
// Eine Regel an einer Stelle. Vorher prüfte nur die Datei-Route den Zugriff;
|
||||||
|
// Umbenennen, Löschen, Wiederholen und ZIP nahmen jede Kennung von jedem
|
||||||
|
// angemeldeten Konto. Bei einem Konto ändert das nichts. Bei Till und Lea hieß
|
||||||
|
// es: Ein Konto konnte per Kennung die Bilder des anderen löschen.
|
||||||
|
import { one } from './db.ts';
|
||||||
|
|
||||||
|
export interface Viewer { uid: string | null; role: string }
|
||||||
|
|
||||||
|
export interface ItemAccessRow {
|
||||||
|
id: string;
|
||||||
|
created_by: string | null;
|
||||||
|
private: boolean | null;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lesen: eigene Bilder, als Admin alle, fremde nur bei geteilter Bibliothek und nie aus einer privaten Sitzung. */
|
||||||
|
export function canRead(v: Viewer, it: Pick<ItemAccessRow, 'created_by' | 'private'>, libraryShared: boolean): boolean {
|
||||||
|
if (v.role === 'admin') return true;
|
||||||
|
if (v.uid && it.created_by === v.uid) return true;
|
||||||
|
return libraryShared && !it.private;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ändern oder Löschen: nur eigene Bilder, oder als Admin. Eine geteilte Bibliothek ist zum Ansehen da, nicht zum Aufräumen fremder Arbeit. */
|
||||||
|
export function canWrite(v: Viewer, it: Pick<ItemAccessRow, 'created_by'>): boolean {
|
||||||
|
if (v.role === 'admin') return true;
|
||||||
|
return !!v.uid && it.created_by === v.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein Bild samt dem, was die Zugriffsentscheidung braucht.
|
||||||
|
* Liefert null für „gibt es nicht" und für „nicht deins" — durch Ausprobieren von
|
||||||
|
* Kennungen soll sich das nicht unterscheiden lassen.
|
||||||
|
*/
|
||||||
|
export async function loadItemFor(
|
||||||
|
v: Viewer, id: string, mode: 'read' | 'write', opts: { includeTrashed?: boolean } = {},
|
||||||
|
): Promise<ItemAccessRow | null> {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(id)) return null;
|
||||||
|
const it = await one<ItemAccessRow & { library_visibility: string | null }>(
|
||||||
|
`SELECT i.id, j.created_by, j.private, i.deleted_at,
|
||||||
|
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
|
||||||
|
FROM items i LEFT JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.id = $1`, [id]);
|
||||||
|
if (!it) return null;
|
||||||
|
if (it.deleted_at && !opts.includeTrashed) return null;
|
||||||
|
const ok = mode === 'write' ? canWrite(v, it) : canRead(v, it, it.library_visibility === 'shared');
|
||||||
|
return ok ? it : null;
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ export function startMaintenance(): void {
|
|||||||
const r = await runRetention(s.retention_days);
|
const r = await runRetention(s.retention_days);
|
||||||
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
|
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
|
||||||
}
|
}
|
||||||
|
const { purgeExpiredTrash } = await import('./trash');
|
||||||
|
const tr = await purgeExpiredTrash();
|
||||||
|
if (tr) console.log(`[maintenance] Papierkorb: ${tr} Bilder endgültig entfernt.`);
|
||||||
const p = await purgePrivate(60);
|
const p = await purgePrivate(60);
|
||||||
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
|
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
|
||||||
const t = await purgeTracking(90);
|
const t = await purgeTracking(90);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function recordRun(e: RunEingang): Promise<string | null> {
|
|||||||
await query(
|
await query(
|
||||||
`INSERT INTO print_run_items (run_id, item_id)
|
`INSERT INTO print_run_items (run_id, item_id)
|
||||||
SELECT $1, x FROM unnest($2::uuid[]) AS x
|
SELECT $1, x FROM unnest($2::uuid[]) AS x
|
||||||
WHERE EXISTS (SELECT 1 FROM items WHERE id = x)
|
WHERE EXISTS (SELECT 1 FROM items WHERE id = x AND deleted_at IS NULL)
|
||||||
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
|
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
|
||||||
console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err));
|
console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err));
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ export async function listRuns(userId: string | null, istAdmin: boolean, limit =
|
|||||||
const bilder = await query<{ run_id: string; id: string; filename: string | null }>(
|
const bilder = await query<{ run_id: string; id: string; filename: string | null }>(
|
||||||
`SELECT ri.run_id, i.id, i.filename
|
`SELECT ri.run_id, i.id, i.filename
|
||||||
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
|
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
|
||||||
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`,
|
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL`,
|
||||||
[runs.map((r) => r.id)]);
|
[runs.map((r) => r.id)]);
|
||||||
|
|
||||||
const nachRun = new Map<string, { id: string; filename: string | null }[]>();
|
const nachRun = new Map<string, { id: string; filename: string | null }[]>();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function loadSourceNamed(src: PrintSource, user: SessionUser): Prom
|
|||||||
if (src.kind === 'item') {
|
if (src.kind === 'item') {
|
||||||
const item = await one<any>(
|
const item = await one<any>(
|
||||||
`SELECT i.result_path, i.filename, j.created_by, j.private
|
`SELECT i.result_path, i.filename, j.created_by, j.private
|
||||||
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
|
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1 AND i.deleted_at IS NULL`, [src.id]);
|
||||||
if (!item?.result_path) throw new Error('Bild nicht gefunden.');
|
if (!item?.result_path) throw new Error('Bild nicht gefunden.');
|
||||||
const isAdmin = user.role === 'admin';
|
const isAdmin = user.role === 'admin';
|
||||||
const own = item.created_by === user.uid;
|
const own = item.created_by === user.uid;
|
||||||
|
|||||||
+8
-2
@@ -61,9 +61,15 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; }
|
|||||||
|
|
||||||
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
|
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
|
||||||
export async function processItem(itemId: string): Promise<ProcessResult> {
|
export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||||
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
|
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null; deleted_at: string | null }>(
|
||||||
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
'SELECT id, job_id, source_path, source_paths, filename, deleted_at FROM items WHERE id=$1', [itemId]);
|
||||||
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
|
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
|
||||||
|
// Noch in der Warteschlange in den Papierkorb gelegt: kein Modell für ein Bild
|
||||||
|
// bezahlen, das niemand mehr will.
|
||||||
|
if (item.deleted_at) {
|
||||||
|
await query(`UPDATE items SET status='skipped' WHERE id=$1 AND status IN ('queued','running')`, [itemId]);
|
||||||
|
return { ok: true, cost: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
||||||
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
|
|||||||
+5
-3
@@ -155,14 +155,16 @@ export async function shareItems(shareId: string): Promise<ShareBild[]> {
|
|||||||
`SELECT i.id, i.filename, i.output_px, i.created_at
|
`SELECT i.id, i.filename, i.output_px, i.created_at
|
||||||
FROM share_items si
|
FROM share_items si
|
||||||
JOIN items i ON i.id = si.item_id
|
JOIN items i ON i.id = si.item_id
|
||||||
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL
|
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL
|
||||||
ORDER BY si.position, i.created_at`, [shareId]);
|
ORDER BY si.position, i.created_at`, [shareId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */
|
/** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */
|
||||||
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
|
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
|
||||||
const r = await one<{ item_id: string }>(
|
const r = await one<{ item_id: string }>(
|
||||||
'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]);
|
// Ein Bild im Papierkorb gehört zu keinem Link, bis es wiederhergestellt ist.
|
||||||
|
`SELECT si.item_id FROM share_items si JOIN items i ON i.id = si.item_id
|
||||||
|
WHERE si.share_id=$1 AND si.item_id=$2 AND i.deleted_at IS NULL`, [shareId, itemId]);
|
||||||
return !!r;
|
return !!r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +237,7 @@ export async function listShares(userId: string | null, istAdmin: boolean): Prom
|
|||||||
// über share_views: Bei vielen Zugriffen wurde diese Seite sonst zäh.
|
// über share_views: Bei vielen Zugriffen wurde diese Seite sonst zäh.
|
||||||
`SELECT s.*,
|
`SELECT s.*,
|
||||||
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
|
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
|
||||||
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS anzahl,
|
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL) AS anzahl,
|
||||||
s.besucher
|
s.besucher
|
||||||
FROM shares s
|
FROM shares s
|
||||||
WHERE $2::boolean OR s.created_by = $1
|
WHERE $2::boolean OR s.created_by = $1
|
||||||
|
|||||||
+6
-1
@@ -310,7 +310,12 @@ async function dispatchPrint(chatId: number, draftId: string, sizeId: string, co
|
|||||||
const label = labelMm(size.w, size.h);
|
const label = labelMm(size.w, size.h);
|
||||||
const bytes = await buildSheetPdf({
|
const bytes = await buildSheetPdf({
|
||||||
sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`,
|
sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`,
|
||||||
footer: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`,
|
// Muss ein FooterSpec sein, kein String: Ein String hat kein `.text`, und der
|
||||||
|
// Bogen kam bisher ohne jede Fußzeile — also ohne den Hinweis auf 100 %.
|
||||||
|
footer: {
|
||||||
|
text: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`,
|
||||||
|
position: 'unten-links', sizePt: 6,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
|
const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Der Papierkorb: sanft löschen, wiederherstellen, leeren — und die Wartung erledigt den Rest.
|
||||||
|
import { one, query } from './db.ts';
|
||||||
|
import { deleteObject } from './storage.ts';
|
||||||
|
import type { Viewer } from './itemaccess.ts';
|
||||||
|
|
||||||
|
export const DEFAULT_TRASH_DAYS = 30;
|
||||||
|
|
||||||
|
/** Jede gespeicherte Datei, auf die eine Bildzeile zeigt. */
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tage, bis ein Bild im Papierkorb endgültig verschwindet. Aufgerundet, nie unter
|
||||||
|
* null: „0 Tage" heißt, der nächste Wartungslauf nimmt es mit.
|
||||||
|
*/
|
||||||
|
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-Bedingung: Zeilen, die dieses Konto ändern darf. `$1` = uid, `$2` = ist Admin. */
|
||||||
|
const OWN = `($2::boolean OR i.job_id IN (SELECT id FROM jobs WHERE created_by = $1))`;
|
||||||
|
|
||||||
|
/** Legt Bilder in den Papierkorb. Liefert die Kennungen, die tatsächlich verschoben wurden. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Bilder endgültig: die Zeile und jede Datei, die kein anderes Bild mehr
|
||||||
|
* nutzt. Das Zweite ist wichtig — eine Alternative teilt ihre Quelldatei mit dem
|
||||||
|
* Original, und das Original zu löschen darf der Alternative nicht die Quelle
|
||||||
|
* wegnehmen.
|
||||||
|
*/
|
||||||
|
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` ist jsonb, kein Postgres-Array. Ein Fehler hier darf die
|
||||||
|
// Schleife nicht abbrechen: Die Zeilen sind schon weg, und eine übersprungene
|
||||||
|
// Datei ist nur verschenkter Platz — eine Ausnahme dagegen überspränge jede
|
||||||
|
// weitere Datei.
|
||||||
|
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('[papierkorb] konnte nicht entfernen', k, (e as Error)?.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leert den Papierkorb: die eigenen Bilder, als Admin alle. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wartung: alles, was länger im Papierkorb liegt, als die Einstellung erlaubt. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ import Base from '../layouts/Base.astro';
|
|||||||
(die Bilder bleiben erhalten).</li>
|
(die Bilder bleiben erhalten).</li>
|
||||||
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
|
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
|
||||||
verschieben.</li>
|
verschieben.</li>
|
||||||
|
<li><b>Löschen</b> (✕) legt ein Bild in den <b>Papierkorb</b>. Direkt danach steht unten „Rückgängig". Im
|
||||||
|
Papierkorb (Knopf oben rechts, sobald etwas drinliegt) bleibt es 30 Tage und lässt sich wiederherstellen —
|
||||||
|
solange es dort liegt, taucht es in keinem geteilten Link und bei keiner Auslieferung auf.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="hinweis"><b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt
|
<p class="hinweis"><b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt
|
||||||
<code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B.
|
<code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B.
|
||||||
|
|||||||
@@ -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,
|
smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
||||||
// Teilen
|
// Teilen
|
||||||
share_default_days: s.share_default_days,
|
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',
|
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
|
||||||
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||||
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path',
|
'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) {
|
if (col in b) {
|
||||||
const raw = b[col];
|
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);
|
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
||||||
set(col, val);
|
set(col, val);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { query, one } from '../../../lib/db';
|
|||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
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 failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
|
||||||
const cost = async (days: number | null) => (await one<{ c: string }>(
|
const cost = async (days: number | null) => (await one<{ c: string }>(
|
||||||
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
|
`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 }) => {
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
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`);
|
FROM folders f ORDER BY f.name`);
|
||||||
return json({ folders: rows });
|
return json({ folders: rows });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { one, query } from '../../../lib/db';
|
import { one } from '../../../lib/db';
|
||||||
import { deleteObject } from '../../../lib/storage';
|
import { loadItemFor } from '../../../lib/itemaccess';
|
||||||
|
import { trashItems, purgeItems } from '../../../lib/trash';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -8,7 +9,8 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
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[] = [];
|
const sets: string[] = []; const args: any[] = [];
|
||||||
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
|
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}`); }
|
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 });
|
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 });
|
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]);
|
const purge = url.searchParams.get('purge') === '1';
|
||||||
if (it?.result_path) await deleteObject(it.result_path).catch(() => {});
|
const it = await loadItemFor(locals.user, params.id!, 'write', { includeTrashed: purge });
|
||||||
await query('DELETE FROM items WHERE id=$1', [params.id]);
|
if (!it) return json({ error: 'Nicht gefunden.' }, 404);
|
||||||
return json({ ok: true });
|
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 });
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { one, query } from '../../../../lib/db';
|
import { one, query } from '../../../../lib/db';
|
||||||
import { enqueue } from '../../../../lib/queue';
|
import { enqueue } from '../../../../lib/queue';
|
||||||
|
import { loadItemFor } from '../../../../lib/itemaccess';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
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. */
|
/** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */
|
||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
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 one<any>(
|
const item = await one<any>(
|
||||||
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
|
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
|
||||||
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
|
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
|
|||||||
try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
|
try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
|
||||||
catch { /* Fallback: Original */ }
|
catch { /* Fallback: Original */ }
|
||||||
}
|
}
|
||||||
return new Response(buf, {
|
return new Response(new Uint8Array(buf), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Cache-Control': 'private, max-age=600',
|
'Cache-Control': 'private, max-age=600',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { one, query } from '../../../../lib/db';
|
import { one, query } from '../../../../lib/db';
|
||||||
import { enqueue } from '../../../../lib/queue';
|
import { enqueue } from '../../../../lib/queue';
|
||||||
|
import { loadItemFor } from '../../../../lib/itemaccess';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -8,6 +9,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
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 one<{ id: string; job_id: string }>(
|
const item = await one<{ id: string; job_id: string }>(
|
||||||
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
|
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
|
||||||
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
|
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { one } from '../../../../lib/db';
|
import { one } from '../../../../lib/db';
|
||||||
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
|
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
|
||||||
|
import { loadItemFor } from '../../../../lib/itemaccess';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -11,6 +12,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
* als Vorlage weiterbearbeitet werden kann. */
|
* als Vorlage weiterbearbeitet werden kann. */
|
||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
if (!(await loadItemFor(locals.user, params.id!, 'read'))) return json({ error: 'Kein Ergebnis vorhanden.' }, 404);
|
||||||
const it = await one<{ result_path: string | null; filename: string | null }>(
|
const it = await one<{ result_path: string | null; filename: string | null }>(
|
||||||
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
|
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
|
||||||
if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404);
|
if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|||||||
const isAdmin = locals.user.role === 'admin';
|
const isAdmin = locals.user.role === 'admin';
|
||||||
const shared = s?.library_visibility === 'shared';
|
const shared = s?.library_visibility === 'shared';
|
||||||
// Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung.
|
// Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung.
|
||||||
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 args: any[] = [];
|
||||||
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
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);
|
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { buildAuswahlZipName } from '../../../lib/naming';
|
import { buildAuswahlZipName } from '../../../lib/naming';
|
||||||
import archiver from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
import { query } from '../../../lib/db';
|
import { query } from '../../../lib/db';
|
||||||
import { getObject } from '../../../lib/storage';
|
import { getObject } from '../../../lib/storage';
|
||||||
|
|
||||||
@@ -14,10 +14,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
if (!ids.length) return new Response('Keine Auswahl.', { status: 400 });
|
if (!ids.length) return new Response('Keine Auswahl.', { status: 400 });
|
||||||
|
|
||||||
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
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]);
|
// Nur Bilder, die dieses Konto sehen darf, und keine aus dem Papierkorb.
|
||||||
|
// Vorher landete jede Kennung jedes Kontos im Archiv.
|
||||||
|
`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('Keine Ergebnisse.', { status: 404 });
|
if (!rows.length) return new Response('Keine Ergebnisse.', { status: 404 });
|
||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||||
const seen: Record<string, number> = {};
|
const seen: Record<string, number> = {};
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const POST: APIRoute = async ({ params, locals }) => {
|
|||||||
break;
|
break;
|
||||||
case 'retry-failed': {
|
case 'retry-failed': {
|
||||||
const items = await query<{ id: string }>(
|
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]);
|
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 });
|
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
||||||
const check = dpiCheck(cropW, size.w, dpi);
|
const check = dpiCheck(cropW, size.w, dpi);
|
||||||
|
|
||||||
return new Response(out.buffer, {
|
return new Response(new Uint8Array(out.buffer), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
|
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
'Content-Disposition': `attachment; filename="${buildSingleFilename({
|
'Content-Disposition': `attachment; filename="${buildSingleFilename({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import archiver from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import { query } from '../../../../lib/db';
|
import { query } from '../../../../lib/db';
|
||||||
@@ -60,11 +60,11 @@ export const GET: APIRoute = async ({ params, request, clientAddress }) => {
|
|||||||
|
|
||||||
const pfade = await query<{ id: string; result_path: string; filename: string | null }>(
|
const pfade = await query<{ id: string; result_path: string; filename: string | null }>(
|
||||||
`SELECT id, result_path, filename FROM items
|
`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`,
|
||||||
[bilder.map((b) => b.id)]);
|
[bilder.map((b) => b.id)]);
|
||||||
const nachId = new Map(pfade.map((p) => [p.id, p]));
|
const nachId = new Map(pfade.map((p) => [p.id, p]));
|
||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||||
// Fehler dürfen den Prozess nicht mitnehmen; der Strom bricht dann ab.
|
// Fehler dürfen den Prozess nicht mitnehmen; der Strom bricht dann ab.
|
||||||
archive.on('error', (e) => console.error('[share/zip]', e?.message || e));
|
archive.on('error', (e) => console.error('[share/zip]', e?.message || e));
|
||||||
archive.on('warning', (e) => console.error('[share/zip] Warnung:', e?.message || e));
|
archive.on('warning', (e) => console.error('[share/zip] Warnung:', e?.message || e));
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
const istAdmin = locals.user.role === 'admin';
|
const istAdmin = locals.user.role === 'admin';
|
||||||
// Eine Abfrage statt einer je Bild: bei 500 Bildern waren das 500 Rundreisen.
|
// Eine Abfrage statt einer je Bild: bei 500 Bildern waren das 500 Rundreisen.
|
||||||
const gefunden = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
|
const gefunden = 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, j.created_by, j.private,
|
`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
|
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
|
||||||
FROM items i JOIN jobs j ON j.id = i.job_id
|
FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
WHERE i.id = ANY($1::uuid[])`, [ids]);
|
WHERE i.id = ANY($1::uuid[])`, [ids]);
|
||||||
if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
|
if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
|
||||||
|
|
||||||
for (const it of gefunden) {
|
for (const it of gefunden) {
|
||||||
if (!it.result_path || it.status !== 'done') return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
|
if (!it.result_path || it.status !== 'done' || it.deleted_at) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
|
||||||
// Private Aufträge werden bewusst nie geteilt — sie sollen ja gerade
|
// Private Aufträge werden bewusst nie geteilt — sie sollen ja gerade
|
||||||
// keine Spur hinterlassen.
|
// keine Spur hinterlassen.
|
||||||
if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403);
|
if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return json({ error: 'Dieser Link ist abgelaufen — erst die Gültigkeit verlängern.' }, 409);
|
return json({ error: 'Dieser Link ist abgelaufen — erst die Gültigkeit verlängern.' }, 409);
|
||||||
|
|
||||||
const url = `${basis()}/s/${share.slug}`;
|
const url = `${basis()}/s/${share.slug}`;
|
||||||
const absender = locals.user.name || locals.user.username || 'Jemand';
|
const absender = locals.user.name || 'Jemand';
|
||||||
const titel = share.title || (share.anzahl > 1 ? `${share.anzahl} Bilder` : 'Ein Bild');
|
const titel = share.title || (share.anzahl > 1 ? `${share.anzahl} Bilder` : 'Ein Bild');
|
||||||
const nachricht = String(b.message || '').trim().slice(0, 1000);
|
const nachricht = String(b.message || '').trim().slice(0, 1000);
|
||||||
const gueltig = share.expires_at
|
const gueltig = share.expires_at
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { query } from '../../lib/db';
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Was im Papierkorb liegt — die eigenen Bilder, als Admin alle. */
|
||||||
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
|
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: [...] } — mehrere auf einmal, eine Rundreise. */
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
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: 'Keine Bilder ausgewählt.' }, 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: 'Unbekannte Aktion.' }, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Leert den Papierkorb. */
|
||||||
|
export const DELETE: APIRoute = async ({ locals }) => {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
return json({ ok: true, purged: await emptyTrash(locals.user) });
|
||||||
|
};
|
||||||
@@ -22,7 +22,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
if (!isImg) { out.push({ filename: file.name, error: 'Nicht unterstütztes Format.' }); continue; }
|
if (!isImg) { out.push({ filename: file.name, error: 'Nicht unterstütztes Format.' }); continue; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let buf = Buffer.from(await file.arrayBuffer());
|
let buf: Buffer = Buffer.from(await file.arrayBuffer());
|
||||||
let ext = 'png';
|
let ext = 'png';
|
||||||
const heic = /heic|heif/i.test(file.type) || /\.(heic|heif)$/i.test(file.name);
|
const heic = /heic|heif/i.test(file.type) || /\.(heic|heif)$/i.test(file.name);
|
||||||
if (heic) buf = await heicToPng(buf);
|
if (heic) buf = await heicToPng(buf);
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ import Base from '../layouts/Base.astro';
|
|||||||
</div>
|
</div>
|
||||||
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
|
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="ver"><span class="tag">10.10.2026</span><h2>Ein Papierkorb, und ein Symbol fürs iPhone</h2></div>
|
||||||
|
<ul>
|
||||||
|
<li><b>Löschen ist nicht mehr sofort endgültig.</b> ✕ legt ein Bild in den Papierkorb, und unten steht für
|
||||||
|
ein paar Sekunden <b>„Rückgängig"</b>. Wer es später merkt: Oben rechts in der Bibliothek erscheint
|
||||||
|
<b>Papierkorb</b>, dort lässt sich jedes Bild 30 Tage lang wiederherstellen. Danach räumt Klarbild es
|
||||||
|
von selbst weg.</li>
|
||||||
|
<li>Solange ein Bild im Papierkorb liegt, ist es auch aus <b>geteilten Links</b> verschwunden — und kommt
|
||||||
|
beim Wiederherstellen dorthin zurück.</li>
|
||||||
|
<li>Wer ein Bild löscht, das noch in der Warteschlange wartet, bezahlt dafür nichts mehr: Es wird gar nicht
|
||||||
|
erst erzeugt.</li>
|
||||||
|
<li><b>Eigenes Symbol.</b> Im Browser-Tab und auf dem iPhone-Homebildschirm („Zum Home-Bildschirm") steht
|
||||||
|
jetzt die Passermarke statt einer leeren Seite.</li>
|
||||||
|
<li><b>Behoben:</b> Druckbogen, die der Telegram-Bot verschickt, hatten keine Fußzeile — also auch nicht den
|
||||||
|
Hinweis, mit 100 % zu drucken. Jetzt wieder.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="ver"><span class="tag">01.09.2026</span><h2>Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast</h2></div>
|
<div class="ver"><span class="tag">01.09.2026</span><h2>Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast</h2></div>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+9
-5
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx"
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": { "@/*": ["src/*"] }
|
|
||||||
},
|
},
|
||||||
"include": ["src", "scripts"],
|
"include": [
|
||||||
"exclude": ["dist", "node_modules"]
|
"src",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user