Löschen in der Bibliothek entfernte Zeile und Ergebnisdatei sofort. Ein Fehlklick, und ein Ergebnis war weg — samt dem, was es gekostet hat. Jetzt landet es im Papierkorb: - weg aus Bibliothek, Freigabelinks, ZIPs, Auslieferung, NAS-Spiegel, Druckquellen und Druckverlauf; beim Wiederherstellen unverändert zurück - „Rückgängig" im Hinweis, Papierkorb-Ansicht mit Resttagen, Wiederherstellen, endgültig löschen, leeren - die Wartung räumt nach settings.trash_days (Standard 30, Admin -> Speicher) endgültig ab; 0 schaltet den Papierkorb aus - ein Bild, das noch in der Warteschlange wartet, wird nicht mehr erzeugt (und nicht bezahlt) - endgültig gelöscht wird nur, was kein anderes Bild mehr nutzt — Alternativen teilen ihre Quelle mit dem Original, auch Kombinieren- Quellen in source_paths (jsonb) Beim Durchgehen jeder Abfrage auf items fiel auf: Nur die Datei-Route prüfte, wer ein Bild sehen darf. Umbenennen, Löschen, Wiederholen, Alternative, Weiterbearbeiten und die Auswahl-ZIP nahmen jede Kennung von jedem Konto. Jetzt gilt eine Regel (src/lib/itemaccess.ts): sehen, was einem gehört oder in der geteilten Bibliothek liegt, ändern nur Eigenes; Admins alles. Für Till ändert sich nichts. Lea kann fremde Bilder in der geteilten Bibliothek weiter sehen, aber nicht mehr löschen. Dazu Favicon und Homebildschirm-Symbol, Hilfe und Changelog-Seite. Geprüft gegen echtes Postgres mit zwei Konten und echten Dateien: 35 Ende-zu-Ende-Prüfungen bestanden, Oberfläche im Browser (Löschen, Rückgängig, Papierkorb, Wiederherstellen, 390 px ohne Querlauf). 128 Tests, Typprüfung 0 Fehler.
59 lines
2.6 KiB
TypeScript
59 lines
2.6 KiB
TypeScript
import type { APIRoute } from 'astro';
|
|
import { buildAuswahlZipName } from '../../../lib/naming';
|
|
import { ZipArchive } from 'archiver';
|
|
import { query } from '../../../lib/db';
|
|
import { getObject } from '../../../lib/storage';
|
|
|
|
export const prerender = false;
|
|
|
|
/** Lädt mehrere Ergebnisse als ZIP. Body { itemIds: [...] } */
|
|
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[] = b.itemIds || [];
|
|
if (!ids.length) return new Response('Keine Auswahl.', { status: 400 });
|
|
|
|
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
|
// 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 });
|
|
|
|
const archive = new ZipArchive({ zlib: { level: 6 } });
|
|
const seen: Record<string, number> = {};
|
|
for (const r of rows) {
|
|
try {
|
|
const buf = await getObject(r.result_path!);
|
|
let name = r.filename || `${r.id}.png`;
|
|
if (!/\.[a-z0-9]+$/i.test(name)) name += '.png';
|
|
// Namenskollisionen vermeiden
|
|
if (seen[name] != null) { const n = ++seen[name]; name = name.replace(/(\.[^.]+)$/, `_${n}$1`); }
|
|
else seen[name] = 0;
|
|
archive.append(buf, { name });
|
|
} catch { /* Datei überspringen */ }
|
|
}
|
|
const done = archive.finalize();
|
|
|
|
// Archiver-Stream (Node) in einen Web-ReadableStream überführen.
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
archive.on('data', (c: Buffer) => controller.enqueue(new Uint8Array(c)));
|
|
archive.on('end', () => controller.close());
|
|
archive.on('error', (e: any) => controller.error(e));
|
|
done.catch(() => {});
|
|
},
|
|
});
|
|
// Eindeutig statt nur datiert: Zwei Auswahlen desselben Tages hießen gleich.
|
|
const name = buildAuswahlZipName(rows.length);
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${name}"`,
|
|
},
|
|
});
|
|
};
|