diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index ec81ae9..b8bdc81 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -319,4 +319,69 @@ router.post('/webdav/sync', authenticate, requireAdmin, async (req, res) => { }); +// ── Waisen aufräumen ────────────────────────────────────────────────────── +// Findet und löscht: +// 1. Dateien auf Disk die nicht in der DB sind (Disk-Waisen) +// 2. DB-Einträge die keine Datei auf Disk haben (DB-Waisen) +router.post('/cleanup-orphans', authenticate, requireAdmin, (req, res) => { + const { dry_run = false } = req.body; + const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + const path = require('path'); + const fs = require('fs'); + + const results = { + disk_orphans: [], // auf Disk aber nicht in DB + db_orphans: [], // in DB aber nicht auf Disk + disk_deleted: 0, + db_deleted: 0, + }; + + try { + // Alle Dateinamen aus DB + const dbFiles = db.prepare('SELECT id, filename, originalname, size FROM files').all(); + const dbFilenames = new Set(dbFiles.map(f => f.filename)); + + // Alle Dateien auf Disk + const diskFiles = fs.readdirSync(UPLOAD_DIR).filter(f => !f.startsWith('.')); + + // 1. Disk-Waisen: auf Disk aber nicht in DB + for (const diskFile of diskFiles) { + if (!dbFilenames.has(diskFile)) { + const fullPath = path.join(UPLOAD_DIR, diskFile); + const stat = fs.statSync(fullPath); + results.disk_orphans.push({ filename: diskFile, size: stat.size }); + if (!dry_run) { + try { fs.unlinkSync(fullPath); results.disk_deleted++; } catch(e) { + console.error('[cleanup] disk unlink failed:', diskFile, e.message); + } + } + } + } + + // 2. DB-Waisen: in DB aber nicht auf Disk + const diskFileset = new Set(diskFiles); + for (const dbFile of dbFiles) { + if (!diskFileset.has(dbFile.filename)) { + results.db_orphans.push({ id: dbFile.id, filename: dbFile.filename, originalname: dbFile.originalname }); + if (!dry_run) { + db.prepare('DELETE FROM files WHERE id=?').run(dbFile.id); + results.db_deleted++; + } + } + } + + res.json({ + dry_run, + disk_orphans_count: results.disk_orphans.length, + db_orphans_count: results.db_orphans.length, + disk_deleted: results.disk_deleted, + db_deleted: results.db_deleted, + disk_orphans: results.disk_orphans.slice(0, 50), + db_orphans: results.db_orphans.slice(0, 50), + }); + } catch(err) { + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/backend/src/tools/dateien/routes.js b/backend/src/tools/dateien/routes.js index c6f0ef4..baff25b 100644 --- a/backend/src/tools/dateien/routes.js +++ b/backend/src/tools/dateien/routes.js @@ -146,16 +146,33 @@ router.post('/folders', authenticate, (req, res) => { }); router.delete('/folders/:id', authenticate, (req, res) => { - const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + const uid = req.user.id; + const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, uid); if (!folder) return res.status(404).json({ error: 'Nicht gefunden' }); - // Delete all files inside - const files = db.prepare('SELECT * FROM files WHERE folder_id=? AND user_id=?').all(folder.id, req.user.id); - for (const f of files) { try { fs.unlinkSync(path.join(UPLOAD_DIR, f.filename)); } catch {} } + + // Alle Unterordner rekursiv sammeln + function collectSubfolderIds(parentId) { + const children = db.prepare('SELECT id FROM folders WHERE parent_id=? AND user_id=?').all(parentId, uid); + let ids = [parentId]; + for (const c of children) ids = ids.concat(collectSubfolderIds(c.id)); + return ids; + } + const allFolderIds = collectSubfolderIds(folder.id); + // WebDAV: Ordner löschen (vor DB-Löschung damit Pfad noch auflösbar) - const _davFolderDel = davFolderUrl(getUserName(req.user.id), folder.id); + const _davFolderDel = davFolderUrl(getUserName(uid), folder.id); if (_davFolderDel) webdav.deleteFile(_davFolderDel).catch(e => console.warn('[webdav] rmdir:', e.message)); - db.prepare('DELETE FROM files WHERE folder_id=? AND user_id=?').run(folder.id, req.user.id); - db.prepare('DELETE FROM folders WHERE id=?').run(folder.id); + + // Alle Dateien in allen Unterordnern löschen (Disk + DB) + for (const fid of allFolderIds) { + const files = db.prepare('SELECT * FROM files WHERE folder_id=? AND user_id=?').all(fid, uid); + for (const f of files) { try { fs.unlinkSync(path.join(UPLOAD_DIR, f.filename)); } catch {} } + db.prepare('DELETE FROM files WHERE folder_id=? AND user_id=?').run(fid, uid); + } + // Alle Unterordner + Hauptordner aus DB löschen + for (const fid of allFolderIds.reverse()) { + db.prepare('DELETE FROM folders WHERE id=?').run(fid); + } res.json({ success: true }); }); @@ -236,7 +253,10 @@ router.post('/', authenticate, (req, res) => { if (usedBytes >= totalLimitMb * 1024 * 1024) return res.status(400).json({ error: `Gesamtlimit von ${totalLimitMb} MB erreicht` }); getUpload().single('file')(req, res, err => { - if (err) return res.status(400).json({ error: err.message }); + if (err) { + if (req.file) try { fs.unlinkSync(req.file.path); } catch {} + return res.status(400).json({ error: err.message }); + } if (!req.file) return res.status(400).json({ error: 'Keine Datei' }); const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null; const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)') @@ -295,7 +315,10 @@ const upload3d = multer({ router.post('/upload-3d', authenticate, (req, res) => { upload3d.array('files', 20)(req, res, err => { - if (err) return res.status(400).json({ error: err.message }); + if (err) { + if (req.files?.length) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} }); + return res.status(400).json({ error: err.message }); + } if (!req.files?.length) return res.status(400).json({ error: 'Keine Dateien' }); const uid = req.user.id; const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1d1f7eb..3a42a7b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2697,6 +2697,81 @@ function UploadShareAdminPanel({ toast }) { +// ── Waisen aufräumen ────────────────────────────────────────────────────── +function OrphanCleanup({ toast }) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + async function run(dry) { + setLoading(true); setResult(null); + try { + const d = await api('/admin/cleanup-orphans', { body: { dry_run: dry } }); + setResult({ ...d, dry }); + } catch(e) { toast('Fehler: ' + e.message); } + finally { setLoading(false); } + } + + function fmtSize(b) { + if (b < 1024) return b + ' B'; + if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB'; + return (b/1024/1024).toFixed(1) + ' MB'; + } + + return ( +