Trash instead of deleting for good — and access checks on every image route
Deleting an image in the library removed the row and the result file on the spot. One slip and a result was gone, including what it had cost to generate. Now a delete moves the image into the trash: - It disappears from the library, from every share link, from ZIPs, from deliveries, the backup mirror, print sources and the print history — and comes back unchanged when restored. - The toast offers "Undo" for seven seconds. A trash view lists what is in there with the days left, restore, delete for good, empty. - Maintenance removes images for good after `settings.trash_days` (default 30, set in Admin -> Storage). 0 switches the trash off. - An image trashed while it is still queued is skipped by the worker instead of being generated — and billed — for nobody. - Deleting for good removes only files no other image still uses. An alternative version shares its source with the original; before, the retention cleanup could take the source of the other one with it. This includes combine sources, which live in jsonb, not in an array. While going through every query that touches images, it turned out that only the file route checked who may see an image. Renaming, deleting, retrying, generating an alternative, reusing and zipping took any id from any signed-in account. On a single-user install that changes nothing; on a family install one account could delete another's images. One rule now lives in src/lib/itemaccess.ts: read your own (or everything in a shared library, never a private session), change only your own; admins all. A refused id answers 404 like a missing one. Also: a favicon and an iPhone home-screen icon. There was none, so the tab showed a blank page and /favicon.ico bounced to the login. Verified against a real Postgres with two accounts and real files on disk: 35 end-to-end checks (access, share links, restore, purge with shared sources, expiry through maintenance, trash switched off). In the browser, English and German: delete, undo, trash view, restore, no horizontal scroll at 390 px. Unit tests: 130 (4 new).
This commit is contained in:
31 files changed
+435
-39
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
-- Trash instead of deleting for good ----------------------------------------
|
||||||
|
--
|
||||||
|
-- Deleting an image used to remove the row and the result file on the spot. One
|
||||||
|
-- slip in the library and the result was gone — including what it had cost to
|
||||||
|
-- generate. Now a delete only sets `deleted_at`. The image disappears from every
|
||||||
|
-- view, every share link and every delivery, and comes back unchanged when it is
|
||||||
|
-- restored. Maintenance removes it for good after `settings.trash_days`.
|
||||||
|
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 = no trash: a delete is final, as before.
|
||||||
|
ALTER TABLE settings ADD COLUMN IF NOT EXISTS trash_days int NOT NULL DEFAULT 30;
|
||||||
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 |
@@ -88,6 +88,7 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
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,
|
||||||
delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_metadata_sidecar,
|
delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_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,
|
||||||
@@ -337,6 +338,9 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
<div className="field"><label>{t('Retention (days)')}</label>
|
<div className="field"><label>{t('Retention (days)')}</label>
|
||||||
<input className="input" type="number" min={0} placeholder={t('empty = unlimited')} value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
<input className="input" type="number" min={0} placeholder={t('empty = unlimited')} value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||||
<div className="hint">{t('Older images are removed automatically (daily). Empty = delete nothing.')}</div></div>
|
<div className="hint">{t('Older images are removed automatically (daily). Empty = delete nothing.')}</div></div>
|
||||||
|
<div className="field"><label>{t('Trash (days)')}</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="hint">{t('Deleted images wait this long in the trash and can be restored until then. 0 = no trash, a delete is final.')}</div></div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<button className="mini" onClick={() => storageAction('purge_sources', t('Delete the source images of all finished items? Results are kept.'))}>{t('Delete sources now')}</button>
|
<button className="mini" onClick={() => storageAction('purge_sources', t('Delete the source images of all finished items? Results are kept.'))}>{t('Delete sources now')}</button>
|
||||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>{t('Generate missing preview images')}</button>
|
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>{t('Generate missing preview images')}</button>
|
||||||
|
|||||||
@@ -30,8 +30,16 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||||
const [share, setShare] = useState<ShareTarget | null>(null);
|
const [share, setShare] = useState<ShareTarget | 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 = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2400); };
|
const toastTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// With an undo button the toast stays longer — long enough to notice the slip.
|
||||||
|
const notify = (msg: string, undo?: () => void) => {
|
||||||
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
|
setToast({ msg, undo });
|
||||||
|
toastTimer.current = setTimeout(() => setToast(null), undo ? 7000 : 2400);
|
||||||
|
};
|
||||||
|
const [trashView, setTrashView] = useState(false);
|
||||||
|
const [trash, setTrash] = useState<{ days: number; items: (Item & { deleted_at: string; days_left: number })[] }>({ days: 30, items: [] });
|
||||||
|
|
||||||
// Model name: the admin label, otherwise the readable tail of the model ID.
|
// Model name: the admin label, otherwise the readable tail of the model ID.
|
||||||
const modelName = (id: string | null) => {
|
const modelName = (id: string | null) => {
|
||||||
@@ -42,12 +50,14 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
};
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [i, f, m] = await Promise.all([
|
const [i, f, m, tr] = 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 || []);
|
||||||
|
setTrash({ days: tr.days ?? 30, items: tr.items || [] });
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@@ -62,8 +72,30 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
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 trashAction = (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(t('Deleted.')); };
|
fetch('/api/trash', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ids }) })
|
||||||
|
.then((r) => r.json()).catch(() => ({ ids: [] }));
|
||||||
|
// Deleting moves into the trash; the toast offers to take it back right there.
|
||||||
|
const del = async (ids: string[]) => {
|
||||||
|
if (!ids.length) return;
|
||||||
|
setItems((x) => x.filter((y) => !ids.includes(y.id)));
|
||||||
|
const r = await trashAction('trash', ids);
|
||||||
|
setPick(new Set()); load();
|
||||||
|
const n = (r.ids || []).length;
|
||||||
|
if (trash.days === 0) { notify(n === 1 ? t('Deleted.') : t('{n} deleted.', { n })); return; }
|
||||||
|
notify(n === 1 ? t('Moved to the trash.') : t('{n} moved to the trash.', { n }),
|
||||||
|
async () => { await trashAction('restore', r.ids || []); setToast(null); load(); });
|
||||||
|
};
|
||||||
|
const delSelected = () => del([...pick]);
|
||||||
|
const restore = async (ids: string[]) => { await trashAction('restore', ids); load(); notify(t('Restored.')); };
|
||||||
|
const purge = async (ids: string[]) => {
|
||||||
|
if (!confirm(ids.length === 1 ? t('Delete this image for good? This cannot be undone.') : t('Delete {n} images for good? This cannot be undone.', { n: ids.length }))) return;
|
||||||
|
await trashAction('purge', ids); load(); notify(t('Deleted for good.'));
|
||||||
|
};
|
||||||
|
const emptyTrash = async () => {
|
||||||
|
if (!confirm(t('Empty the trash? {n} images will be deleted for good.', { n: trash.items.length }))) return;
|
||||||
|
await fetch('/api/trash', { method: 'DELETE' }); load(); notify(t('Trash emptied.'));
|
||||||
|
};
|
||||||
const deliverSelected = async () => {
|
const deliverSelected = async () => {
|
||||||
notify(t('Sending to the delivery target …'));
|
notify(t('Sending to the delivery target …'));
|
||||||
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());
|
||||||
@@ -131,9 +163,10 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="head-row">
|
<div className="head-row">
|
||||||
<span className="mono-label">{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
<span className="mono-label">{trashView ? t('Trash · {n}', { n: trash.items.length })
|
||||||
|
: <>{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}</>}</span>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{pick.size > 0 && <>
|
{pick.size > 0 && !trashView && <>
|
||||||
<span className="hint">{t('{n} selected', { n: pick.size })}</span>
|
<span className="hint">{t('{n} selected', { n: pick.size })}</span>
|
||||||
<button className="mini strong" onClick={deliverSelected}>{t('Deliver')}</button>
|
<button className="mini strong" onClick={deliverSelected}>{t('Deliver')}</button>
|
||||||
<button className="mini" onClick={() => setShare({ itemIds: [...pick] })}>{t('Share')}</button>
|
<button className="mini" onClick={() => setShare({ itemIds: [...pick] })}>{t('Share')}</button>
|
||||||
@@ -145,10 +178,43 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
</select>
|
</select>
|
||||||
<button className="mini" onClick={delSelected}>{t('Delete')}</button>
|
<button className="mini" onClick={delSelected}>{t('Delete')}</button>
|
||||||
</>}
|
</>}
|
||||||
<button className="mini" onClick={newFolder}>{t('New folder')}</button>
|
{!trashView && <button className="mini" onClick={newFolder}>{t('New folder')}</button>}
|
||||||
|
{(trash.items.length > 0 || trashView) && (
|
||||||
|
<button className={`mini ${trashView ? 'strong' : ''}`} onClick={() => setTrashView(!trashView)}>
|
||||||
|
{trashView ? t('Back to the library') : t('Trash · {n}', { n: trash.items.length })}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{trashView ? (
|
||||||
|
<div className="trash">
|
||||||
|
<div className="hint notice">{trash.days > 0
|
||||||
|
? t('Deleted images stay here for {days} days and can be restored until then. They are hidden from the library, from every share link and from every delivery.', { days: trash.days })
|
||||||
|
: t('The trash is switched off — deleting is final.')}</div>
|
||||||
|
{trash.items.length === 0 ? (
|
||||||
|
<div className="empty"><span className="regmark" /><h3>{t('The trash is empty')}</h3></div>
|
||||||
|
) : (<>
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<button className="mini" onClick={() => restore(trash.items.map((x) => x.id))}>{t('Restore all')}</button>
|
||||||
|
<button className="mini danger" onClick={emptyTrash}>{t('Empty the trash')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="gallery">
|
||||||
|
{trash.items.map((v) => (
|
||||||
|
<article key={v.id} className="tile trashed">
|
||||||
|
<div className="tile-image"><img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" /></div>
|
||||||
|
<div className="name static">{v.filename || '—'}</div>
|
||||||
|
<div className="meta">{v.days_left === 0 ? t('Removed at the next cleanup') : v.days_left === 1 ? t('1 day left') : t('{n} days left', { n: v.days_left })}</div>
|
||||||
|
<div className="row tight">
|
||||||
|
<button className="mini strong2" onClick={() => restore([v.id])}>{t('Restore')}</button>
|
||||||
|
<button className="mini" onClick={() => purge([v.id])}>{t('Delete for good')}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
|
|
||||||
{/* Folder filter */}
|
{/* Folder filter */}
|
||||||
<div className="folderbar">
|
<div className="folderbar">
|
||||||
<button className={`chip ${!folderFilter ? 'active' : ''}`} onClick={() => setFolderFilter(null)}>{t('All')} · {items.length}</button>
|
<button className={`chip ${!folderFilter ? 'active' : ''}`} onClick={() => setFolderFilter(null)}>{t('All')} · {items.length}</button>
|
||||||
@@ -238,7 +304,7 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
<option value="">{t('No folder')}</option>
|
<option value="">{t('No folder')}</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={t('Move to the trash')}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -246,6 +312,8 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
{share && (
|
{share && (
|
||||||
<ShareDialog target={share} onClose={() => setShare(null)} locale={locale} dict={dict} />
|
<ShareDialog target={share} onClose={() => setShare(null)} locale={locale} dict={dict} />
|
||||||
)}
|
)}
|
||||||
@@ -286,7 +354,8 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
</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}>{t('Undo')}</button>}</div>}
|
||||||
<LibStyles />
|
<LibStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -359,6 +428,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-head{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
.pb-head{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.danger{border-color:var(--err);color:var(--err);}
|
||||||
|
.tile.trashed .tile-image img{opacity:.6;filter:grayscale(.6);}
|
||||||
|
.name.static{font-family:var(--font-mono);font-size:12px;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
`}</style>;
|
`}</style>;
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,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
@@ -64,7 +64,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
|
|||||||
/** Work through all deliveries of a job that are still open (pending). */
|
/** Work through all deliveries of a job that are still open (pending). */
|
||||||
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 @@
|
|||||||
|
// Who may do what with an image.
|
||||||
|
//
|
||||||
|
// One rule, in one place. Before this file, only the file route checked access;
|
||||||
|
// renaming, deleting, retrying and zipping took any id from any signed-in user.
|
||||||
|
// On a single-user install that changes nothing. On a family install it meant
|
||||||
|
// one account could delete another account's images by id.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reading: your own images, everything as admin, others' only in a shared library and never from a private session. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changing or deleting: only your own images, or as admin. A shared library is for looking, not for tidying up other people's work. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an image together with what the access decision needs.
|
||||||
|
* Returns null both for "does not exist" and for "not yours" — a caller must not
|
||||||
|
* be able to tell the two apart by trying ids.
|
||||||
|
*/
|
||||||
|
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} old items removed.`);
|
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} old items removed.`);
|
||||||
}
|
}
|
||||||
|
const { purgeExpiredTrash } = await import('./trash');
|
||||||
|
const tr = await purgeExpiredTrash();
|
||||||
|
if (tr) console.log(`[maintenance] Trash: ${tr} images removed for good.`);
|
||||||
const p = await purgePrivate(60);
|
const p = await purgePrivate(60);
|
||||||
if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`);
|
if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`);
|
||||||
const t = await purgeTracking(90);
|
const t = await purgeTracking(90);
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function recordRun(e: RunInput): 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] image references not stored:', err?.message || err));
|
console.error('[printruns] image references not stored:', err?.message || err));
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ export async function listRuns(userId: string | null, isAdmin: boolean, limit =
|
|||||||
const images = await query<{ run_id: string; id: string; filename: string | null }>(
|
const images = 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 byRun = new Map<string, { id: string; filename: string | null }[]>();
|
const byRun = 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('Image not found.');
|
if (!item?.result_path) throw new Error('Image not found.');
|
||||||
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; }
|
|||||||
|
|
||||||
/** Processes one item completely and updates its record. */
|
/** Processes one item completely and updates its record. */
|
||||||
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: 'Item not found' };
|
if (!item) return { ok: false, cost: 0, error: 'Item not found' };
|
||||||
|
// Moved to the trash while it was still waiting: do not pay a model for an
|
||||||
|
// image nobody wants any more.
|
||||||
|
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
@@ -153,14 +153,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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this image belong to this link? Cross-check before every delivery. */
|
/** Does this image belong to this link? Cross-check before every delivery. */
|
||||||
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]);
|
// An image in the trash is not part of any link until it is restored.
|
||||||
|
`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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +235,7 @@ export async function listShares(userId: string | null, isAdmin: boolean): Promi
|
|||||||
// share_views: with many accesses this page turned sluggish otherwise.
|
// share_views: with many accesses this page turned sluggish otherwise.
|
||||||
`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 count,
|
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL) AS count,
|
||||||
s.visitor
|
s.visitor
|
||||||
FROM shares s
|
FROM shares s
|
||||||
WHERE $2::boolean OR s.created_by = $1
|
WHERE $2::boolean OR s.created_by = $1
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// The trash: delete softly, restore, empty, and let maintenance finish the job.
|
||||||
|
import { one, query } from './db.ts';
|
||||||
|
import { deleteObject } from './storage.ts';
|
||||||
|
import type { Viewer } from './itemaccess.ts';
|
||||||
|
|
||||||
|
export const DEFAULT_TRASH_DAYS = 30;
|
||||||
|
|
||||||
|
/** Every stored object an image row points to. */
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days until an image in the trash is removed for good. Rounded up, never below
|
||||||
|
* zero: "0 days" means the next maintenance run takes it.
|
||||||
|
*/
|
||||||
|
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 condition: rows this viewer may change. `$1` = uid, `$2` = is admin. */
|
||||||
|
const OWN = `($2::boolean OR i.job_id IN (SELECT id FROM jobs WHERE created_by = $1))`;
|
||||||
|
|
||||||
|
/** Moves images into the trash. Returns the ids that actually moved. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes images for good: the row and every file that no other image still
|
||||||
|
* uses. That last part matters — an alternative version shares its source file
|
||||||
|
* with the original, and deleting the original must not take the source of the
|
||||||
|
* alternative with it.
|
||||||
|
*/
|
||||||
|
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` is jsonb, not a Postgres array. A failure here must not stop
|
||||||
|
// the loop: the rows are already gone, and a skipped file is only wasted
|
||||||
|
// space, while an exception would skip every file after it.
|
||||||
|
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('[trash] could not remove', k, (e as Error)?.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empties the trash: the viewer's own images, or all of them for an admin. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maintenance: everything that has been in the trash longer than the setting allows. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
".md on": ".md an",
|
".md on": ".md an",
|
||||||
"0 = unlimited.": "0 = unbegrenzt.",
|
"0 = unlimited.": "0 = unbegrenzt.",
|
||||||
"1 Sep 2026": "01.09.2026",
|
"1 Sep 2026": "01.09.2026",
|
||||||
|
"1 day left": "Noch 1 Tag",
|
||||||
"1 image": "1 Bild",
|
"1 image": "1 Bild",
|
||||||
"1 year": "1 Jahr",
|
"1 year": "1 Jahr",
|
||||||
"1 year from now": "1 Jahr ab jetzt",
|
"1 year from now": "1 Jahr ab jetzt",
|
||||||
@@ -218,6 +219,7 @@
|
|||||||
"Assign images to this folder from the folder menu.": "Weise Bildern über das Ordner-Menü diesen Ordner zu.",
|
"Assign images to this folder from the folder menu.": "Weise Bildern über das Ordner-Menü diesen Ordner zu.",
|
||||||
"Automated tests for the geometry plus a raster sample of the finished PDFs at 254 dpi (= 10 px/mm) with edge measurement.": "Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.",
|
"Automated tests for the geometry plus a raster sample of the finished PDFs at 254 dpi (= 10 px/mm) with edge measurement.": "Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.",
|
||||||
"Automation / MCP access": "Automatisierung / MCP-Zugriff",
|
"Automation / MCP access": "Automatisierung / MCP-Zugriff",
|
||||||
|
"Back to the library": "Zurück zur Bibliothek",
|
||||||
"Back up all existing images to the mirror": "Alle vorhandenen Bilder auf den Sicherungsspiegel sichern",
|
"Back up all existing images to the mirror": "Alle vorhandenen Bilder auf den Sicherungsspiegel sichern",
|
||||||
"Back up all finished images to every backup target (mirror + marked targets)?": "Alle fertigen Bilder auf alle Backup-Ziele (Sicherungsspiegel + markierte Ziele) sichern?",
|
"Back up all finished images to every backup target (mirror + marked targets)?": "Alle fertigen Bilder auf alle Backup-Ziele (Sicherungsspiegel + markierte Ziele) sichern?",
|
||||||
"Back up all finished images to the mirror?": "Alle fertigen Bilder auf den Sicherungsspiegel sichern?",
|
"Back up all finished images to the mirror?": "Alle fertigen Bilder auf den Sicherungsspiegel sichern?",
|
||||||
@@ -349,6 +351,7 @@
|
|||||||
"Delete": "Löschen",
|
"Delete": "Löschen",
|
||||||
"Delete folder": "Ordner löschen",
|
"Delete folder": "Ordner löschen",
|
||||||
"Delete folder? The images are kept.": "Ordner löschen? Die Bilder bleiben erhalten.",
|
"Delete folder? The images are kept.": "Ordner löschen? Die Bilder bleiben erhalten.",
|
||||||
|
"Delete for good": "Endgültig löschen",
|
||||||
"Delete items older than {days} days now?": "Positionen älter als {days} Tage jetzt löschen?",
|
"Delete items older than {days} days now?": "Positionen älter als {days} Tage jetzt löschen?",
|
||||||
"Delete preset": "Vorlage löschen",
|
"Delete preset": "Vorlage löschen",
|
||||||
"Delete preset?": "Vorlage löschen?",
|
"Delete preset?": "Vorlage löschen?",
|
||||||
@@ -357,7 +360,12 @@
|
|||||||
"Delete target?": "Ziel löschen?",
|
"Delete target?": "Ziel löschen?",
|
||||||
"Delete the link \"{name}\" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.": "Den Link „{name}\" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. Nur abschalten reicht meistens.",
|
"Delete the link \"{name}\" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.": "Den Link „{name}\" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. Nur abschalten reicht meistens.",
|
||||||
"Delete the source images of all finished items? Results are kept.": "Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.",
|
"Delete the source images of all finished items? Results are kept.": "Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.",
|
||||||
|
"Delete this image for good? This cannot be undone.": "Dieses Bild endgültig löschen? Das lässt sich nicht rückgängig machen.",
|
||||||
"Delete this job?": "Diesen Auftrag löschen?",
|
"Delete this job?": "Diesen Auftrag löschen?",
|
||||||
|
"Delete {n} images for good? This cannot be undone.": "{n} Bilder endgültig löschen? Das lässt sich nicht rückgängig machen.",
|
||||||
|
"Deleted for good.": "Endgültig gelöscht.",
|
||||||
|
"Deleted images stay here for {days} days and can be restored until then. They are hidden from the library, from every share link and from every delivery.": "Gelöschte Bilder bleiben {days} Tage hier und lassen sich bis dahin wiederherstellen. In der Bibliothek, in Freigabelinks und bei der Auslieferung tauchen sie nicht mehr auf.",
|
||||||
|
"Deleted images wait this long in the trash and can be restored until then. 0 = no trash, a delete is final.": "So lange bleiben gelöschte Bilder im Papierkorb und lassen sich bis dahin wiederherstellen. 0 = kein Papierkorb, Löschen ist endgültig.",
|
||||||
"Deleted.": "Gelöscht.",
|
"Deleted.": "Gelöscht.",
|
||||||
"Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.": "Löscht alle Bilder, Aufträge und Ordner unwiderruflich, um sauber neu zu starten. Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder auf den Sicherungsspiegel sichern\".",
|
"Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.": "Löscht alle Bilder, Aufträge und Ordner unwiderruflich, um sauber neu zu starten. Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder auf den Sicherungsspiegel sichern\".",
|
||||||
"Deliver": "Ausliefern",
|
"Deliver": "Ausliefern",
|
||||||
@@ -388,6 +396,8 @@
|
|||||||
"Email address and notifications.": "E-Mail-Adresse und Benachrichtigungen.",
|
"Email address and notifications.": "E-Mail-Adresse und Benachrichtigungen.",
|
||||||
"Empty = everyone with the link gets in": "Leer = jeder mit dem Link kommt rein",
|
"Empty = everyone with the link gets in": "Leer = jeder mit dem Link kommt rein",
|
||||||
"Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.": "Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.",
|
"Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.": "Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.",
|
||||||
|
"Empty the trash": "Papierkorb leeren",
|
||||||
|
"Empty the trash? {n} images will be deleted for good.": "Papierkorb leeren? {n} Bilder werden endgültig gelöscht.",
|
||||||
"Enable the backup mirror and save first.": "Sicherungsspiegel erst aktivieren & speichern.",
|
"Enable the backup mirror and save first.": "Sicherungsspiegel erst aktivieren & speichern.",
|
||||||
"Enter a host and save first.": "Erst Host eintragen und speichern.",
|
"Enter a host and save first.": "Erst Host eintragen und speichern.",
|
||||||
"Enter a new key to replace it": "Neuen Key eingeben zum Wechseln",
|
"Enter a new key to replace it": "Neuen Key eingeben zum Wechseln",
|
||||||
@@ -523,7 +533,9 @@
|
|||||||
"Monthly budget ($)": "Monatliches Budget ($)",
|
"Monthly budget ($)": "Monatliches Budget ($)",
|
||||||
"More output targets (FTP/SFTP)": "Weitere Ausgabeziele (FTP/SFTP)",
|
"More output targets (FTP/SFTP)": "Weitere Ausgabeziele (FTP/SFTP)",
|
||||||
"More settings": "Mehr einstellen",
|
"More settings": "Mehr einstellen",
|
||||||
|
"Move to the trash": "In den Papierkorb",
|
||||||
"Moved into the folder.": "In Ordner verschoben.",
|
"Moved into the folder.": "In Ordner verschoben.",
|
||||||
|
"Moved to the trash.": "In den Papierkorb gelegt.",
|
||||||
"My account": "Mein Konto",
|
"My account": "Mein Konto",
|
||||||
"My account · Klarbild": "Mein Konto · Klarbild",
|
"My account · Klarbild": "Mein Konto · Klarbild",
|
||||||
"My account, sharing & email": "Mein Konto, Teilen & E-Mail",
|
"My account, sharing & email": "Mein Konto, Teilen & E-Mail",
|
||||||
@@ -689,6 +701,7 @@
|
|||||||
"Remove frame, wall and shop interface.": "Rahmen, Wand und Shop-Oberfläche entfernen.",
|
"Remove frame, wall and shop interface.": "Rahmen, Wand und Shop-Oberfläche entfernen.",
|
||||||
"Remove {images}?": "{images} entfernen?",
|
"Remove {images}?": "{images} entfernen?",
|
||||||
"Remove {name}": "{name} entfernen",
|
"Remove {name}": "{name} entfernen",
|
||||||
|
"Removed at the next cleanup": "Wird beim nächsten Aufräumen entfernt",
|
||||||
"Removed from the folder.": "Aus Ordner entfernt.",
|
"Removed from the folder.": "Aus Ordner entfernt.",
|
||||||
"Rename folder": "Ordner umbenennen",
|
"Rename folder": "Ordner umbenennen",
|
||||||
"Rename folder:": "Ordner umbenennen:",
|
"Rename folder:": "Ordner umbenennen:",
|
||||||
@@ -699,6 +712,9 @@
|
|||||||
"Reset library & jobs": "Bibliothek & Aufträge zurücksetzen",
|
"Reset library & jobs": "Bibliothek & Aufträge zurücksetzen",
|
||||||
"Resetting …": "Setze zurück …",
|
"Resetting …": "Setze zurück …",
|
||||||
"Resolution (dpi)": "Auflösung (dpi)",
|
"Resolution (dpi)": "Auflösung (dpi)",
|
||||||
|
"Restore": "Wiederherstellen",
|
||||||
|
"Restore all": "Alle wiederherstellen",
|
||||||
|
"Restored.": "Wiederhergestellt.",
|
||||||
"Results land under base folder / YYYY-MM /. Credentials are stored encrypted.": "Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.",
|
"Results land under base folder / YYYY-MM /. Credentials are stored encrypted.": "Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.",
|
||||||
"Resume": "Fortsetzen",
|
"Resume": "Fortsetzen",
|
||||||
"Retention (days)": "Aufbewahrung (Tage)",
|
"Retention (days)": "Aufbewahrung (Tage)",
|
||||||
@@ -834,6 +850,8 @@
|
|||||||
"The problem": "Das Problem",
|
"The problem": "Das Problem",
|
||||||
"The same image once more — for a second format, say": "Dasselbe Bild noch einmal — z. B. für ein zweites Format",
|
"The same image once more — for a second format, say": "Dasselbe Bild noch einmal — z. B. für ein zweites Format",
|
||||||
"The second way out for large sizes: the image is spread across several sheets. The tiles overlap by a glue flap, which is printed on both neighbouring sheets and marked with a dashed line. Every sheet carries its number with row and column.": "Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.",
|
"The second way out for large sizes: the image is spread across several sheets. The tiles overlap by a glue flap, which is printed on both neighbouring sheets and marked with a dashed line. Every sheet carries its number with row and column.": "Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.",
|
||||||
|
"The trash is empty": "Der Papierkorb ist leer",
|
||||||
|
"The trash is switched off — deleting is final.": "Der Papierkorb ist abgeschaltet — Löschen ist endgültig.",
|
||||||
"The two entries do not match.": "Die beiden Eingaben stimmen nicht überein.",
|
"The two entries do not match.": "Die beiden Eingaben stimmen nicht überein.",
|
||||||
"The usual route runs through a layout program or through the printer driver, and both of them mean too well: “fit to page”, “scale to fit”, “scale borderless”. What comes out is a print that is almost right. For a passport photo, “almost” is too little, and using a whole A4 sheet for a 4 × 3 snapshot, only because it would otherwise not fit on the page at 100% size, is plain waste.": "Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein 4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in 100 % Größe auf die Seite passt, ist schlicht Verschwendung.",
|
"The usual route runs through a layout program or through the printer driver, and both of them mean too well: “fit to page”, “scale to fit”, “scale borderless”. What comes out is a print that is almost right. For a passport photo, “almost” is too little, and using a whole A4 sheet for a 4 × 3 snapshot, only because it would otherwise not fit on the page at 100% size, is plain waste.": "Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein 4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in 100 % Größe auf die Seite passt, ist schlicht Verschwendung.",
|
||||||
"The whole image stays visible, with a border around it": "Ganzes Bild bleibt sichtbar, außen entsteht ein Rand",
|
"The whole image stays visible, with a border around it": "Ganzes Bild bleibt sichtbar, außen entsteht ein Rand",
|
||||||
@@ -855,12 +873,16 @@
|
|||||||
"Today ${today} · 7 days ${week}": "Heute ${today} · 7 Tage ${week}",
|
"Today ${today} · 7 days ${week}": "Heute ${today} · 7 Tage ${week}",
|
||||||
"Too large": "Zu groß",
|
"Too large": "Zu groß",
|
||||||
"Too many attempts. Please try again in a quarter of an hour.": "Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.",
|
"Too many attempts. Please try again in a quarter of an hour.": "Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.",
|
||||||
|
"Trash (days)": "Papierkorb (Tage)",
|
||||||
|
"Trash emptied.": "Papierkorb geleert.",
|
||||||
|
"Trash · {n}": "Papierkorb · {n}",
|
||||||
"True to size": "Maßhaltig",
|
"True to size": "Maßhaltig",
|
||||||
"Try again": "Noch einmal versuchen",
|
"Try again": "Noch einmal versuchen",
|
||||||
"US visa 51 × 51 mm (2 × 2 in)": "Visum USA 51 × 51 mm (2 × 2 in)",
|
"US visa 51 × 51 mm (2 × 2 in)": "Visum USA 51 × 51 mm (2 × 2 in)",
|
||||||
"Under <b>“File names”</b> the existing library can be brought onto the unique naming scheme. <b>“Check library”</b> first shows what would change and how many names are currently used twice; <b>“Rename now”</b> carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. <b>“Deliver again”</b> then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.": "Unter <b>„Dateinamen\"</b> lässt sich die vorhandene Bibliothek auf das eindeutige Namensschema bringen. <b>„Bibliothek prüfen\"</b> zeigt zuerst, was sich ändern würde und wie viele Namen aktuell doppelt vergeben sind; <b>„Jetzt umbenennen\"</b> setzt es um. Die Bilddateien selbst bleiben dabei unangetastet — sie liegen im Speicher unter ihrer Kennung, nicht unter ihrem Namen. Ein zweiter Lauf ändert nichts mehr. <b>„Erneut ausliefern\"</b> lädt danach alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch; die alten Dateien bleiben am Ziel liegen und räumst du dort selbst weg.",
|
"Under <b>“File names”</b> the existing library can be brought onto the unique naming scheme. <b>“Check library”</b> first shows what would change and how many names are currently used twice; <b>“Rename now”</b> carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. <b>“Deliver again”</b> then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.": "Unter <b>„Dateinamen\"</b> lässt sich die vorhandene Bibliothek auf das eindeutige Namensschema bringen. <b>„Bibliothek prüfen\"</b> zeigt zuerst, was sich ändern würde und wie viele Namen aktuell doppelt vergeben sind; <b>„Jetzt umbenennen\"</b> setzt es um. Die Bilddateien selbst bleiben dabei unangetastet — sie liegen im Speicher unter ihrer Kennung, nicht unter ihrem Namen. Ein zweiter Lauf ändert nichts mehr. <b>„Erneut ausliefern\"</b> lädt danach alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch; die alten Dateien bleiben am Ziel liegen und räumst du dort selbst weg.",
|
||||||
"Under the hood": "Unter der Haube",
|
"Under the hood": "Unter der Haube",
|
||||||
"Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.": "Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose für den Spiegel.",
|
"Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.": "Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose für den Spiegel.",
|
||||||
|
"Undo": "Rückgängig",
|
||||||
"Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr",
|
"Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr",
|
||||||
"Unit": "Einheit",
|
"Unit": "Einheit",
|
||||||
"Unlink": "Trennen",
|
"Unlink": "Trennen",
|
||||||
@@ -1015,6 +1037,8 @@
|
|||||||
"{images} on this sheet": "{images} auf diesem Bogen",
|
"{images} on this sheet": "{images} auf diesem Bogen",
|
||||||
"{n} copies to backup targets, {failed} errors.": "{n} Kopien auf Backup-Ziele, {failed} Fehler.",
|
"{n} copies to backup targets, {failed} errors.": "{n} Kopien auf Backup-Ziele, {failed} Fehler.",
|
||||||
"{n} copies to backup targets.": "{n} Kopien auf Backup-Ziele.",
|
"{n} copies to backup targets.": "{n} Kopien auf Backup-Ziele.",
|
||||||
|
"{n} days left": "Noch {n} Tage",
|
||||||
|
"{n} deleted.": "{n} gelöscht.",
|
||||||
"{n} entries in {path}": "{n} Einträge in {path}",
|
"{n} entries in {path}": "{n} Einträge in {path}",
|
||||||
"{n} extra sheet": "{n} Extrabogen",
|
"{n} extra sheet": "{n} Extrabogen",
|
||||||
"{n} extra sheets": "{n} Extrabogen",
|
"{n} extra sheets": "{n} Extrabogen",
|
||||||
@@ -1029,6 +1053,7 @@
|
|||||||
"{n} images renamed, {ok} were already fine.": "{n} Bilder umbenannt, {ok} waren schon in Ordnung.",
|
"{n} images renamed, {ok} were already fine.": "{n} Bilder umbenannt, {ok} waren schon in Ordnung.",
|
||||||
"{n} leftovers removed from “{folder}”.": "{n} Reste in „{folder}\" entfernt.",
|
"{n} leftovers removed from “{folder}”.": "{n} Reste in „{folder}\" entfernt.",
|
||||||
"{n} min ago": "vor {n} min",
|
"{n} min ago": "vor {n} min",
|
||||||
|
"{n} moved to the trash.": "{n} in den Papierkorb gelegt.",
|
||||||
"{n} names were used twice": "{n} Namen waren doppelt vergeben",
|
"{n} names were used twice": "{n} Namen waren doppelt vergeben",
|
||||||
"{n} of {checked} images would get a new name — {dupes} names are currently used twice.": "{n} von {checked} Bildern bekämen einen neuen Namen — {dupes} Namen sind aktuell doppelt vergeben.",
|
"{n} of {checked} images would get a new name — {dupes} names are currently used twice.": "{n} von {checked} Bildern bekämen einen neuen Namen — {dupes} Namen sind aktuell doppelt vergeben.",
|
||||||
"{n} of {checked} images would get a new name.": "{n} von {checked} Bildern bekämen einen neuen Namen.",
|
"{n} of {checked} images would get a new name.": "{n} von {checked} Bildern bekämen einen neuen Namen.",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const GET: APIRoute = async () => {
|
|||||||
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
||||||
// Sharing
|
// Sharing
|
||||||
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 }) => {
|
|||||||
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
|
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
|
||||||
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||||
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
|
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
|
||||||
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
|
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days', 'trash_days']) {
|
||||||
if (col in b) {
|
if (col in b) {
|
||||||
const raw = b[col];
|
const raw = b[col];
|
||||||
|
if (col === 'trash_days') {
|
||||||
|
// NOT NULL in the database: empty means the default, anything else a whole number of days.
|
||||||
|
const n = raw === '' || raw == null ? 30 : Math.round(Number(raw));
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 3650) return json({ error: 'Trash: 0 to 3650 days.' }, 400);
|
||||||
|
set(col, n);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
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: 'Not found.' }, 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 }) => {
|
/**
|
||||||
|
* Moves the image into the trash. `?purge=1` removes an image that is already in
|
||||||
|
* the trash for good. With the trash switched off (trash_days = 0) a delete is
|
||||||
|
* final straight away, as it used to be.
|
||||||
|
*/
|
||||||
|
export const DELETE: APIRoute = async ({ params, url, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
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: 'Not found.' }, 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 });
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ 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 { has, moduleOff } from '../../../../lib/modules';
|
import { has, moduleOff } from '../../../../lib/modules';
|
||||||
|
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) =>
|
|||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!has('ai')) return moduleOff('ai');
|
if (!has('ai')) return moduleOff('ai');
|
||||||
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: 'Not found.' }, 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: 'Not found.' }, 404);
|
if (!item) return json({ error: 'Not found.' }, 404);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { has, moduleOff } from '../../../../lib/modules';
|
import { has, moduleOff } from '../../../../lib/modules';
|
||||||
|
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) =>
|
||||||
@@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!has('ai')) return moduleOff('ai');
|
if (!has('ai')) return moduleOff('ai');
|
||||||
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: 'Not found.' }, 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: 'Not found.' }, 404);
|
if (!item) return json({ error: 'Not found.' }, 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) =>
|
|||||||
* the studio as a starting point. */
|
* the studio as a starting point. */
|
||||||
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: 'There is no result.' }, 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: 'There is no result.' }, 404);
|
if (!it?.result_path) return json({ error: 'There is no result.' }, 404);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|||||||
const isAdmin = locals.user.role === 'admin';
|
const isAdmin = locals.user.role === 'admin';
|
||||||
const shared = s?.library_visibility === 'shared';
|
const shared = s?.library_visibility === 'shared';
|
||||||
// Private jobs never show up in the library; visibility follows the setting.
|
// Private jobs never show up in the library; visibility follows the setting.
|
||||||
const where: string[] = [`i.status='done'`, `j.private=false`];
|
const where: string[] = [`i.status='done'`, `j.private=false`, `i.deleted_at IS NULL`];
|
||||||
const args: any[] = [];
|
const 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);
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const ids: string[] = b.itemIds || [];
|
const ids: string[] = b.itemIds || [];
|
||||||
if (!ids.length) return new Response('Nothing selected.', { status: 400 });
|
if (!ids.length) return new Response('Nothing selected.', { status: 400 });
|
||||||
|
|
||||||
|
// Only images this account may see, and none from the trash. Before, any id
|
||||||
|
// from any account went into the archive.
|
||||||
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
||||||
`SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]);
|
`SELECT i.id, i.result_path, i.filename FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.id = ANY($1::uuid[]) AND i.result_path IS NOT NULL AND i.deleted_at IS NULL
|
||||||
|
AND ($3::boolean OR j.created_by = $2
|
||||||
|
OR (NOT j.private AND (SELECT library_visibility FROM settings WHERE id=1) = 'shared'))`,
|
||||||
|
[ids.filter((x) => /^[0-9a-f-]{36}$/i.test(String(x))), locals.user.uid, locals.user.role === 'admin']);
|
||||||
if (!rows.length) return new Response('No results.', { status: 404 });
|
if (!rows.length) return new Response('No results.', { status: 404 });
|
||||||
|
|
||||||
const archive = new ZipArchive({ zlib: { level: 6 } });
|
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||||
|
|||||||
@@ -31,7 +31,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;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const GET: APIRoute = async ({ params, request, clientAddress }) => {
|
|||||||
|
|
||||||
const paths = await query<{ id: string; result_path: string; filename: string | null }>(
|
const paths = 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`,
|
||||||
[images.map((b) => b.id)]);
|
[images.map((b) => b.id)]);
|
||||||
const byId = new Map(paths.map((p) => [p.id, p]));
|
const byId = new Map(paths.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
const isAdmin = locals.user.role === 'admin';
|
const isAdmin = locals.user.role === 'admin';
|
||||||
// One query instead of one per image: with 500 images that was 500 round trips.
|
// One query instead of one per image: with 500 images that was 500 round trips.
|
||||||
const found = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
|
const found = await query<{ id: string; status: string; result_path: string | null; deleted_at: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
|
||||||
`SELECT i.id, i.status, i.result_path, 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 (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404);
|
if (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404);
|
||||||
|
|
||||||
for (const it of found) {
|
for (const it of found) {
|
||||||
if (!it.result_path || it.status !== 'done') return json({ error: 'One of the images is no longer available.' }, 404);
|
if (!it.result_path || it.status !== 'done' || it.deleted_at) return json({ error: 'One of the images is no longer available.' }, 404);
|
||||||
// Private jobs are deliberately never shared — the whole point is that they
|
// Private jobs are deliberately never shared — the whole point is that they
|
||||||
// leave no trace.
|
// leave no trace.
|
||||||
if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403);
|
if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { query } from '../../lib/db';
|
||||||
|
import { has, moduleOff } from '../../lib/modules';
|
||||||
|
import { trashItems, restoreItems, purgeItems, emptyTrash, trashDays, daysLeft } from '../../lib/trash';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
const UUID = /^[0-9a-f-]{36}$/i;
|
||||||
|
|
||||||
|
/** What is in the trash — your own images, or all of them for an admin. */
|
||||||
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const days = await trashDays();
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.thumb_path, i.deleted_at, i.created_at
|
||||||
|
FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.deleted_at IS NOT NULL AND ($2::boolean OR j.created_by = $1)
|
||||||
|
ORDER BY i.deleted_at DESC LIMIT 400`, [locals.user.uid, locals.user.role === 'admin']);
|
||||||
|
return json({ days, items: rows.map((r) => ({ ...r, days_left: daysLeft(r.deleted_at, days) })) });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Body { action: 'trash' | 'restore' | 'purge', ids: [...] } — several at once, one round trip. */
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const b = await request.json().catch(() => ({} as any));
|
||||||
|
const ids: string[] = Array.isArray(b.ids) ? b.ids.filter((x: unknown) => typeof x === 'string' && UUID.test(x)).slice(0, 500) : [];
|
||||||
|
if (!ids.length) return json({ error: 'No images selected.' }, 400);
|
||||||
|
if (b.action === 'trash') return json({ ok: true, ids: await trashItems(locals.user, ids) });
|
||||||
|
if (b.action === 'restore') return json({ ok: true, ids: await restoreItems(locals.user, ids) });
|
||||||
|
if (b.action === 'purge') return json({ ok: true, ids: await purgeItems(locals.user, ids) });
|
||||||
|
return json({ error: 'Unknown action.' }, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Empties the trash. */
|
||||||
|
export const DELETE: APIRoute = async ({ locals }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
return json({ ok: true, purged: await emptyTrash(locals.user) });
|
||||||
|
};
|
||||||
@@ -68,6 +68,10 @@ world such a module does not exist.
|
|||||||
- GET /api/jobs — the most recent jobs. GET /api/jobs/:id — job plus items (incl. error_message, finished_at).
|
- GET /api/jobs — the most recent jobs. GET /api/jobs/:id — job plus items (incl. error_message, finished_at).
|
||||||
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
|
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
|
||||||
- POST /api/items/:id/{retry|reuse|alternative}.
|
- POST /api/items/:id/{retry|reuse|alternative}.
|
||||||
|
- DELETE /api/items/:id — moves the image into the trash (restorable for settings.trash_days, default 30).
|
||||||
|
?purge=1 removes an image that is already in the trash for good.
|
||||||
|
- GET /api/trash — the trash, with days_left per image. POST /api/trash { action: trash|restore|purge, ids[] }.
|
||||||
|
DELETE /api/trash — empty it. Only your own images (admins: all).
|
||||||
- GET /api/items/:id/file — the result image. Query: ?thumb=1 (preview), ?preview=1 (small JPG for full screen),
|
- GET /api/items/:id/file — the result image. Query: ?thumb=1 (preview), ?preview=1 (small JPG for full screen),
|
||||||
?download=1 (as a file), ?src=1 (the source). The content type is taken from the magic bytes (PNG/JPG/WEBP).
|
?download=1 (as a file), ?src=1 (the source). The content type is taken from the magic bytes (PNG/JPG/WEBP).
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user