'use strict'; const express = require('express'); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); const { db, DB_PATH } = require('../db'); const { log } = require('../lib/audit'); const { requireAuth, requireAdmin } = require('../middleware/auth'); const router = express.Router(); router.use(requireAuth, requireAdmin); const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); const BRAND_DIR = process.env.BRAND_DIR || path.join(__dirname, '..', 'brand'); /** Total bytes of every file directly inside a directory (non-recursive dirs handled too). */ async function dirSize(dir) { let bytes = 0; let count = 0; let entries; try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch (_) { return { bytes: 0, count: 0, missing: true }; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { const sub = await dirSize(full); bytes += sub.bytes; count += sub.count; } else if (e.isFile()) { try { const st = await fsp.stat(full); bytes += st.size; count += 1; } catch (_) { /* file vanished between listing and stat */ } } } return { bytes, count, missing: false }; } /** Size of the SQLite database, including its write-ahead log if present. */ async function databaseSize() { let bytes = 0; for (const suffix of ['', '-wal', '-shm']) { try { const st = await fsp.stat(DB_PATH + suffix); bytes += st.size; } catch (_) { /* not present */ } } return bytes; } /** Free/used figures for the filesystem holding a given path. */ async function diskUsage(forPath) { const s = await fsp.statfs(forPath); const blockSize = s.bsize; const total = s.blocks * blockSize; // bavail is what a normal user may actually use; bfree includes blocks // reserved for root, so using bavail avoids overstating free space. const available = s.bavail * blockSize; const used = (s.blocks - s.bfree) * blockSize; const usableTotal = used + available; return { total, used, free: available, // Percentage is taken against what is actually usable, which is how `df` // reports it, so the number matches what an administrator sees on the host. percent_used: usableTotal > 0 ? Math.round((used / usableTotal) * 1000) / 10 : 0, }; } /** * Per-folder usage, built from the recorded file sizes. * own_bytes = files sitting directly in the folder * total_bytes = the folder and everything beneath it */ function folderUsage() { const folders = db.prepare('SELECT id, name, parent_id FROM folders ORDER BY name').all(); const files = db.prepare('SELECT id, name, size, folder_id, stored_name FROM files').all(); const own = new Map(); const ownCount = new Map(); let rootBytes = 0; let rootCount = 0; for (const f of files) { const key = f.folder_id || null; const size = Number(f.size) || 0; if (key === null) { rootBytes += size; rootCount += 1; continue; } own.set(key, (own.get(key) || 0) + size); ownCount.set(key, (ownCount.get(key) || 0) + 1); } const children = new Map(); for (const f of folders) { const k = f.parent_id || null; if (!children.has(k)) children.set(k, []); children.get(k).push(f); } const totals = new Map(); const totalCounts = new Map(); function walk(folder) { let bytes = own.get(folder.id) || 0; let count = ownCount.get(folder.id) || 0; for (const child of children.get(folder.id) || []) { const sub = walk(child); bytes += sub.bytes; count += sub.count; } totals.set(folder.id, bytes); totalCounts.set(folder.id, count); return { bytes, count }; } for (const root of children.get(null) || []) walk(root); // Path label so deeply nested folders are identifiable in a flat table. const byId = new Map(folders.map((f) => [f.id, f])); function pathOf(f) { const parts = []; let cur = f; const guard = new Set(); while (cur && !guard.has(cur.id)) { guard.add(cur.id); parts.unshift(cur.name); cur = cur.parent_id ? byId.get(cur.parent_id) : null; } return parts.join(' / '); } const rows = folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id, path: pathOf(f), depth: pathOf(f).split(' / ').length - 1, own_bytes: own.get(f.id) || 0, own_files: ownCount.get(f.id) || 0, total_bytes: totals.get(f.id) || 0, total_files: totalCounts.get(f.id) || 0, })); return { rows, rootBytes, rootCount, recordedTotal: files.reduce((a, f) => a + (Number(f.size) || 0), 0) }; } /** * Cross-check the database against the uploads directory: files recorded but * missing from disk, and files on disk no longer referenced by any record. */ async function integrity() { const rows = db.prepare('SELECT id, name, stored_name, size FROM files').all(); const known = new Set(rows.map((r) => r.stored_name)); const missing = []; for (const r of rows) { try { await fsp.stat(path.join(UPLOAD_DIR, r.stored_name)); } catch (_) { missing.push({ id: r.id, name: r.name, stored_name: r.stored_name }); } } let orphans = []; let orphanBytes = 0; try { const entries = await fsp.readdir(UPLOAD_DIR, { withFileTypes: true }); for (const e of entries) { if (!e.isFile() || e.name === '.gitkeep') continue; if (known.has(e.name)) continue; try { const st = await fsp.stat(path.join(UPLOAD_DIR, e.name)); orphans.push({ stored_name: e.name, size: st.size }); orphanBytes += st.size; } catch (_) { /* ignore */ } } } catch (_) { /* uploads dir unreadable */ } return { missing, orphans, orphan_bytes: orphanBytes }; } router.get('/', async (req, res) => { try { const [disk, uploads, brand, dbBytes, checks] = await Promise.all([ diskUsage(UPLOAD_DIR), dirSize(UPLOAD_DIR), dirSize(BRAND_DIR), databaseSize(), integrity(), ]); const usage = folderUsage(); res.json({ disk, app: { uploads_bytes: uploads.bytes, uploads_files: uploads.count, brand_bytes: brand.bytes, brand_files: brand.count, database_bytes: dbBytes, total_bytes: uploads.bytes + brand.bytes + dbBytes, }, folders: usage.rows, root_files: { bytes: usage.rootBytes, count: usage.rootCount }, recorded_total: usage.recordedTotal, integrity: checks, paths: { uploads: UPLOAD_DIR, brand: BRAND_DIR, database: DB_PATH }, generated_at: new Date().toISOString().replace('T', ' ').slice(0, 19), }); } catch (e) { res.status(500).json({ error: `Could not read storage information: ${e.message}` }); } }); // Remove files left on disk that no record points at (safe to reclaim). router.post('/cleanup-orphans', async (req, res) => { const checks = await integrity(); let removed = 0; let bytes = 0; for (const o of checks.orphans) { try { await fsp.unlink(path.join(UPLOAD_DIR, o.stored_name)); removed += 1; bytes += o.size; } catch (_) { /* leave it alone if it cannot be removed */ } } log(req, 'STORAGE_CLEANUP', 'Storage', `Removed ${removed} orphaned file(s), reclaiming ${bytes} bytes`); res.json({ ok: true, removed, bytes }); }); module.exports = router;