230 lines
7.2 KiB
JavaScript
230 lines
7.2 KiB
JavaScript
'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;
|