From e2345c659dbd645651f1bb827b8ced664c6b6759 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:04:11 -0700 Subject: [PATCH] Add mass orphan safety guard to prevent accidental library deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When >50% of files are flagged as orphans (likely a DB path mismatch), findings are marked as warnings with mass_orphan flag. Fixing these requires typing "witness me" to confirm — prevents nuking an entire library from a false-positive orphan scan. --- core/repair_jobs/orphan_file_detector.py | 85 ++++++++++------ webui/static/script.js | 121 +++++++++++++++++++++-- 2 files changed, 167 insertions(+), 39 deletions(-) diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 771a2842..5f2de742 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -81,6 +81,8 @@ class OrphanFileDetectorJob(RepairJob): if context.report_progress: context.report_progress(phase=f'Checking {total} files...', total=total) + orphan_files = [] + for i, fpath in enumerate(audio_files): if context.check_stop(): return result @@ -107,41 +109,62 @@ class OrphanFileDetectorJob(RepairJob): break if not is_known: - # This file is an orphan — create finding - if context.report_progress: - context.report_progress( - log_line=f'Orphan: {os.path.basename(fpath)}', - log_type='skip' - ) - try: - stat = os.stat(fpath) - ext = os.path.splitext(fpath)[1].lower().lstrip('.') - if context.create_finding: - context.create_finding( - job_id=self.job_id, - finding_type='orphan_file', - severity='info', - entity_type='file', - entity_id=None, - file_path=fpath, - title=f'Orphan file: {os.path.basename(fpath)}', - description=f'Audio file in transfer folder is not tracked in the database', - details={ - 'file_size': stat.st_size, - 'format': ext, - 'modified': time.strftime('%Y-%m-%d %H:%M:%S', - time.localtime(stat.st_mtime)), - 'folder': os.path.dirname(fpath), - } - ) - result.findings_created += 1 - except Exception as e: - logger.debug("Error creating orphan finding for %s: %s", fpath, e) - result.errors += 1 + orphan_files.append(fpath) if context.update_progress and (i + 1) % 50 == 0: context.update_progress(i + 1, total) + # Safety check: if most files look like orphans, it's probably a path + # mismatch between the DB and filesystem — not actual orphans. + orphan_ratio = len(orphan_files) / total if total else 0 + mass_orphan = orphan_ratio > 0.5 and len(orphan_files) > 20 + + if mass_orphan: + logger.warning( + "Mass orphan warning: %d of %d files (%.0f%%) flagged as orphans — " + "this likely indicates a DB path mismatch, not actual orphans", + len(orphan_files), total, orphan_ratio * 100 + ) + + for fpath in orphan_files: + if context.report_progress: + context.report_progress( + log_line=f'Orphan: {os.path.basename(fpath)}', + log_type='skip' + ) + try: + stat = os.stat(fpath) + ext = os.path.splitext(fpath)[1].lower().lstrip('.') + if context.create_finding: + context.create_finding( + job_id=self.job_id, + finding_type='orphan_file', + severity='warning' if mass_orphan else 'info', + entity_type='file', + entity_id=None, + file_path=fpath, + title=f'Orphan file: {os.path.basename(fpath)}', + description=( + 'Audio file in transfer folder is not tracked in the database. ' + 'WARNING: Mass orphan detection triggered — this may be a path ' + 'mismatch, not actual orphans. Verify before deleting!' + ) if mass_orphan else ( + 'Audio file in transfer folder is not tracked in the database' + ), + details={ + 'file_size': stat.st_size, + 'format': ext, + 'modified': time.strftime('%Y-%m-%d %H:%M:%S', + time.localtime(stat.st_mtime)), + 'folder': os.path.dirname(fpath), + 'mass_orphan': mass_orphan, + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating orphan finding for %s: %s", fpath, e) + result.errors += 1 + if context.update_progress: context.update_progress(total, total) diff --git a/webui/static/script.js b/webui/static/script.js index 803abeeb..ce33955f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -172,6 +172,92 @@ function resolveConfirmDialog(result) { } } +/** + * Nuclear confirmation dialog for mass-destructive operations. + * User must type an exact phrase to proceed. + */ +function showWitnessMeDialog(orphanCount) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'confirm-modal-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; + + overlay.innerHTML = ` +
+

Mass Deletion Warning

+

+ You are about to permanently delete ${orphanCount.toLocaleString()} files from your disk. +

+

+ This many orphans usually means a path mismatch between your database and filesystem + — not actual orphan files. A previous user lost their entire library this way. +

+

+ To confirm you understand the risk, type witness me below: +

+ +
+ + +
+
+ `; + + document.body.appendChild(overlay); + + const input = overlay.querySelector('#witness-me-input'); + const confirmBtn = overlay.querySelector('#witness-confirm'); + const cancelBtn = overlay.querySelector('#witness-cancel'); + + input.addEventListener('input', () => { + const match = input.value.trim().toLowerCase() === 'witness me'; + confirmBtn.disabled = !match; + confirmBtn.style.background = match ? '#e74c3c' : '#555'; + confirmBtn.style.color = match ? '#fff' : '#888'; + confirmBtn.style.cursor = match ? 'pointer' : 'not-allowed'; + }); + + confirmBtn.addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(true); + }); + + cancelBtn.addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(false); + }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + document.body.removeChild(overlay); + resolve(false); + } + }); + + setTimeout(() => input.focus(), 100); + }); +} + +const MASS_ORPHAN_THRESHOLD = 20; + +function _isMassOrphanFix(jobId, count) { + if (count <= MASS_ORPHAN_THRESHOLD) return false; + // Only trigger if mass_orphan flag is actually set on visible findings + // (flag is set by backend when >50% of files are orphans — likely path mismatch) + if (jobId === 'orphan_file_detector' || !jobId) { + const massCards = document.querySelectorAll('.repair-finding-card[data-mass-orphan="true"]'); + if (massCards.length > 0) return true; + } + return false; +} + // =============================== // WEBSOCKET CONNECTION MANAGER // =============================== @@ -52848,7 +52934,7 @@ async function loadRepairFindings() { const filePath = f.file_path || d.original_path || d.file_path || ''; const fixLabel = fixableTypes[f.finding_type]; - return `
+ return `
@@ -53227,13 +53313,18 @@ async function fixAllMatchingFindings() { const jobId = jobFilter ? jobFilter.value : ''; const severity = severityFilter ? severityFilter.value : ''; - const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; - if (!await showConfirmDialog({ - title: 'Fix All Findings', - message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`, - confirmText: 'Fix All', - destructive: true - })) return; + // Mass orphan safety gate + if (_isMassOrphanFix(jobId, _repairFindingsTotal)) { + if (!await showWitnessMeDialog(_repairFindingsTotal)) return; + } else { + const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; + if (!await showConfirmDialog({ + title: 'Fix All Findings', + message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`, + confirmText: 'Fix All', + destructive: true + })) return; + } showToast(`Fixing ${_repairFindingsTotal} findings...`, 'info'); @@ -53375,6 +53466,20 @@ async function bulkRepairAction(action) { async function bulkFixFindings() { if (_repairSelectedFindings.size === 0) return; const ids = Array.from(_repairSelectedFindings); + + // Mass orphan safety gate — check if selected findings include mass-orphan flagged cards + const selectedOrphanCards = ids.filter(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.jobId === 'orphan_file_detector'; + }); + if (selectedOrphanCards.length > MASS_ORPHAN_THRESHOLD) { + const hasMassFlag = ids.some(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.massOrphan === 'true'; + }); + if (hasMassFlag && !await showWitnessMeDialog(selectedOrphanCards.length)) return; + } + let fixed = 0, failed = 0; showToast(`Fixing ${ids.length} findings...`, 'info');