fix: rekursive Ordner-Löschung, Multer-Cleanup, Waisen-Bereinigung (Disk+DB)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
|
||||
Findet und löscht Dateien die auf Disk aber nicht in der DB sind (z.B. nach Ordner-Lösch-Bugs),
|
||||
und DB-Einträge ohne Datei auf Disk.
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,marginBottom:12,flexWrap:'wrap'}}>
|
||||
<button onClick={()=>run(true)} disabled={loading}
|
||||
style={{...S.btn('#60a5fa'),opacity:loading?0.5:1}}>
|
||||
{loading?'⏳...':'🔍 Analysieren (kein Löschen)'}
|
||||
</button>
|
||||
<button onClick={()=>run(false)} disabled={loading}
|
||||
style={{...S.btn('#f87171'),opacity:loading?0.5:1}}>
|
||||
{loading?'⏳...':'🗑 Waisen löschen'}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{padding:'12px 14px',background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:8,fontFamily:'monospace',fontSize:11}}>
|
||||
<div style={{color:'rgba(255,255,255,0.6)',marginBottom:8}}>
|
||||
{result.dry ? '🔍 Analyse (nichts gelöscht)' : '🗑 Bereinigung abgeschlossen'}
|
||||
</div>
|
||||
<div style={{color:result.disk_orphans_count>0?'#f87171':'#4ade80',marginBottom:4}}>
|
||||
Disk-Waisen: {result.disk_orphans_count}
|
||||
{!result.dry && result.disk_deleted > 0 && <span style={{color:'#4ade80'}}> ({result.disk_deleted} gelöscht)</span>}
|
||||
</div>
|
||||
<div style={{color:result.db_orphans_count>0?'#f87171':'#4ade80',marginBottom:8}}>
|
||||
DB-Waisen: {result.db_orphans_count}
|
||||
{!result.dry && result.db_deleted > 0 && <span style={{color:'#4ade80'}}> ({result.db_deleted} gelöscht)</span>}
|
||||
</div>
|
||||
{result.disk_orphans.length > 0 && (
|
||||
<div style={{marginBottom:6}}>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontSize:9,marginBottom:4}}>DISK-WAISEN:</div>
|
||||
{result.disk_orphans.map((f,i) => (
|
||||
<div key={i} style={{color:'rgba(255,255,255,0.3)',fontSize:9}}>
|
||||
· {f.filename} ({fmtSize(f.size)})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.db_orphans.length > 0 && (
|
||||
<div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontSize:9,marginBottom:4}}>DB-WAISEN:</div>
|
||||
{result.db_orphans.map((f,i) => (
|
||||
<div key={i} style={{color:'rgba(255,255,255,0.3)',fontSize:9}}>
|
||||
· {f.originalname} ({f.filename})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
|
||||
function WebDavSettings({ toast }) {
|
||||
const [cfg, setCfg] = useState({ url:'', user:'', password:'', basePath:'/dickendock', enabled:false });
|
||||
@@ -2704,6 +2779,8 @@ function WebDavSettings({ toast }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState(null);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [pullResult, setPullResult] = useState(null);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2833,6 +2910,33 @@ function WebDavSettings({ toast }) {
|
||||
{syncResult?.error && (
|
||||
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>✗ {syncResult.error}</div>
|
||||
)}
|
||||
|
||||
{/* Pull: Synology → App */}
|
||||
<div style={{marginTop:14,borderTop:'1px solid rgba(255,255,255,0.07)',paddingTop:14}}>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>SYNOLOGY → APP ABGLEICHEN</div>
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginBottom:10,lineHeight:1.5}}>
|
||||
Liest neue/gelöschte Ordner von der Synology und spiegelt sie in die App.
|
||||
</div>
|
||||
<button onClick={async()=>{
|
||||
setPulling(true); setPullResult(null);
|
||||
try {
|
||||
const d = await api('/admin/webdav/pull', { body: {} });
|
||||
setPullResult(d);
|
||||
} catch(e) { setPullResult({ error: e.message }); }
|
||||
finally { setPulling(false); }
|
||||
}} disabled={pulling}
|
||||
style={{...S.btn('#60a5fa'), opacity:pulling?0.5:1, marginBottom:8}}>
|
||||
{pulling ? '⏳ Lese Synology...' : '📥 Synology → App abgleichen'}
|
||||
</button>
|
||||
{pullResult && !pullResult.error && (
|
||||
<div style={{padding:'8px 12px',background:'rgba(96,165,250,0.08)',border:'1px solid rgba(96,165,250,0.3)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#60a5fa'}}>
|
||||
✓ {pullResult.message}
|
||||
</div>
|
||||
)}
|
||||
{pullResult?.error && (
|
||||
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>✗ {pullResult.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3031,6 +3135,9 @@ function AdminPanel({ toast, mobile, user, nav }) {
|
||||
<Sec title="SYNOLOGY NAS / WEBDAV">
|
||||
<WebDavSettings toast={toast}/>
|
||||
</Sec>
|
||||
<Sec title="DATEI-SPEICHER AUFRÄUMEN">
|
||||
<OrphanCleanup toast={toast}/>
|
||||
</Sec>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user