/** * lib_delete.mjs — Safe file deletion helpers for symlink-deduplicated uploads. * * When bypass_duplicate_check is active, reposts are stored as relative symlinks * pointing at the original file. If the original item is deleted, we must not * remove the real file from disk as long as at least one other non-deleted item * still references it. * * Strategy for deleteMediaFile(dest): * 1. lstat(b/dest) to check if it's a symlink or a real file. * 2. If symlink → safe to just unlink it (other items are unaffected). * 3. If real file → query the DB for any other non-deleted item whose dest * resolves to the same real filename (i.e. items that are symlinks HERE * pointing at `dest`). * - If none → the file is exclusively owned; unlink it. * - If found → one of those symlinks must become the new canonical file. * Rename the real file to `survivorDest` and update its symlink to * point at itself (i.e. remove its symlink and leave the real file). * The item being deleted just loses its file entry (it was the real one). */ import { promises as fs } from 'fs'; import path from 'path'; import db from './sql.mjs'; import cfg from './config.mjs'; /** * Safely remove the media file for a deleted item. * * @param {string} dest - The `dest` filename of the item being deleted (e.g. "531f3737.webm") * @param {number} deletedId - The item ID being deleted (used to exclude it from survivor query) * @returns {Promise} */ export async function safeDeleteMediaFile(dest, deletedId) { const filePath = path.join(cfg.paths.b, dest); let lstat; try { lstat = await fs.lstat(filePath); } catch (e) { // File doesn't exist in public dir — nothing to do return; } if (lstat.isSymbolicLink()) { // Just a symlink — safe to remove, real data is unaffected await fs.unlink(filePath).catch(() => {}); console.error(`[DELETE] Unlinked symlink b/${dest}`); return; } // It's a real file. Check if any other active (non-deleted, non-purged) item // references this filename via a symlink — i.e. items whose dest, when resolved, // matches `dest`. const survivors = await db` SELECT id, dest FROM items WHERE id != ${deletedId} AND is_deleted = false AND is_purged = false AND active = true AND dest IS NOT NULL `; // Find which of those survivors are symlinks pointing at our file const symlinkSurvivors = []; for (const s of survivors) { const sPath = path.join(cfg.paths.b, s.dest); try { const sLstat = await fs.lstat(sPath); if (sLstat.isSymbolicLink()) { const target = await fs.readlink(sPath); // target is relative (e.g. "531f3737.webm"), resolve relative to b/ const resolvedTarget = path.resolve(path.dirname(sPath), target); if (resolvedTarget === filePath) { symlinkSurvivors.push(s); } } } catch (e) { // Skip missing files } } if (symlinkSurvivors.length === 0) { // No other item references this real file — safe to unlink await fs.unlink(filePath).catch(() => {}); console.error(`[DELETE] Unlinked real file b/${dest} (no surviving references)`); return; } // At least one surviving item symlinks to this file. // Promote the first survivor: rename the real file to its dest name, // and remove its old symlink. All other survivors then need to point at // the new canonical name. const promoted = symlinkSurvivors[0]; const promotedPath = path.join(cfg.paths.b, promoted.dest); try { // Remove the promoted item's symlink await fs.unlink(promotedPath); // Move the real file to the promoted dest name await fs.rename(filePath, promotedPath); console.error(`[DELETE] Promoted b/${promoted.dest} as new canonical file (was b/${dest})`); // Update remaining symlinks to point at the new canonical name for (const s of symlinkSurvivors.slice(1)) { const sPath = path.join(cfg.paths.b, s.dest); try { await fs.unlink(sPath); await fs.symlink(promoted.dest, sPath); console.error(`[DELETE] Re-targeted symlink b/${s.dest} → ${promoted.dest}`); } catch (e) { console.error(`[DELETE] Failed to re-target b/${s.dest}:`, e.message); } } } catch (e) { console.error(`[DELETE] Failed to promote b/${promoted.dest}:`, e.message); // Last resort: just unlink original (data may be lost, but avoid leaving dangling state) await fs.unlink(filePath).catch(() => {}); } } /** * Move a media file from b/ to deleted/b/ safely (for soft-delete / trash flow). * If the file is a symlink, we copy the real data to deleted/b/ so it's self-contained, * then remove the symlink from b/. * If it's the real file and other items reference it, promote a survivor first. * * @param {string} dest * @param {number} deletedId */ export async function moveToDeleted(dest, deletedId) { const srcPath = path.join(cfg.paths.b, dest); const dstPath = path.join(cfg.paths.deleted, 'b', dest); let lstat; try { lstat = await fs.lstat(srcPath); } catch (e) { return; // Nothing to move } if (lstat.isSymbolicLink()) { // Copy the resolved real data to deleted/ (so the trash viewer works standalone) await fs.copyFile(srcPath, dstPath).catch(() => {}); await fs.unlink(srcPath).catch(() => {}); console.error(`[DELETE] Moved symlink b/${dest} to deleted/ (copied real data)`); return; } // Real file — check for survivors before removing from b/ const survivors = await db` SELECT id, dest FROM items WHERE id != ${deletedId} AND is_deleted = false AND is_purged = false AND active = true AND dest IS NOT NULL `; const symlinkSurvivors = []; for (const s of survivors) { const sPath = path.join(cfg.paths.b, s.dest); try { const sLstat = await fs.lstat(sPath); if (sLstat.isSymbolicLink()) { const target = await fs.readlink(sPath); const resolvedTarget = path.resolve(path.dirname(sPath), target); if (resolvedTarget === srcPath) { symlinkSurvivors.push(s); } } } catch (e) {} } // Copy real data to deleted/ regardless await fs.copyFile(srcPath, dstPath).catch(() => {}); if (symlinkSurvivors.length === 0) { // No survivors — remove the real file from b/ await fs.unlink(srcPath).catch(() => {}); console.error(`[DELETE] Moved real file b/${dest} to deleted/`); return; } // Promote a survivor to hold the real file const promoted = symlinkSurvivors[0]; const promotedPath = path.join(cfg.paths.b, promoted.dest); try { await fs.unlink(promotedPath); // remove symlink await fs.rename(srcPath, promotedPath); // real file takes promoted's name console.error(`[DELETE] Promoted b/${promoted.dest} as canonical; moved b/${dest} to deleted/`); // Re-target remaining survivors to point at the new canonical name for (const s of symlinkSurvivors.slice(1)) { const sPath = path.join(cfg.paths.b, s.dest); try { await fs.unlink(sPath); await fs.symlink(promoted.dest, sPath); } catch (e) {} } } catch (e) { console.error(`[DELETE] Promotion failed for b/${promoted.dest}:`, e.message); await fs.unlink(srcPath).catch(() => {}); } }