import { readFile, realpath, unlink, writeFile, rename } from 'node:fs/promises'; import { dirname, relative, isAbsolute, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; /** Only confirmed publications may release their public source images. */ export async function cleanupPublished(recordPath: string, imageDir: string) { const record = JSON.parse(await readFile(recordPath, 'utf8')); if (record.status !== 'published' || !record.postId || record.imagesDeletedAt) return false; const root = await realpath(imageDir).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return resolve(imageDir); throw error; }); const paths: string[] = []; for (const image of record.images) { let path: string; try { path = await realpath(image.imagePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; } const child = relative(root, path); if (!child || child.startsWith('..') || isAbsolute(child) || dirname(path) !== root) { throw new Error('Refusing image cleanup outside the capture directory'); } paths.push(path); } for (const path of paths) { try { await unlink(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } const temporary = `${recordPath}.${randomUUID()}.tmp`; await writeFile(temporary, JSON.stringify({ ...record, imagesDeletedAt: new Date().toISOString() }, null, 2), { mode: 0o600 }); await rename(temporary, recordPath); return true; }