Files
ISDSS---Internal-Survailanc…/routes/storage.js
T
2026-09-13 20:12:28 +01:00

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;