Files

171 lines
7.6 KiB
JavaScript
Raw Permalink Normal View History

2026-09-13 20:12:28 +01:00
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const multer = require('multer');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { isValidDate, legalValidity, withValidity, parseInputDate } = require('../lib/dates');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) =>
cb(null, `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${path.extname(file.originalname)}`),
});
const upload = multer({ storage, limits: { fileSize: 1024 * 1024 * 200 } }); // 200MB
router.use(requireAuth, requireAdmin);
// ---- Tree / listing ---------------------------------------------------
router.get('/tree', (req, res) => {
const folders = db.prepare(
'SELECT id, name, parent_id, recorded_date FROM folders ORDER BY name'
).all().map(withValidity);
const files = db.prepare(
'SELECT id, name, size, mime, folder_id, created_at FROM files ORDER BY name'
).all();
res.json({ folders, files });
});
// ---- Folders ----------------------------------------------------------
router.post('/folders', (req, res) => {
const { name, parent_id, recorded_date } = req.body || {};
if (!name || !name.trim()) return res.status(400).json({ error: 'Folder name required.' });
// Every folder must carry the date its material was recorded; the Legal
// Validity date shown to users is derived from it.
const typed = String(recorded_date || '').trim();
if (!typed) return res.status(400).json({ error: 'A recorded date is required for the folder.' });
const recorded = parseInputDate(typed); // accepts DD-MM-YYYY or YYYY-MM-DD
if (!recorded) {
return res.status(400).json({ error: 'Recorded date must be a valid date in DD-MM-YYYY format.' });
}
const info = db.prepare(
'INSERT INTO folders (name, parent_id, recorded_date, created_by) VALUES (?, ?, ?, ?)'
).run(name.trim(), parent_id || null, recorded, req.session.user.id);
log(req, 'FOLDER_CREATE', 'Data Management',
`Created folder "${name}" (id ${info.lastInsertRowid}) recorded ${recorded}, valid until ${legalValidity(recorded)}`);
res.json({ ok: true, id: info.lastInsertRowid, recorded_date: recorded, legal_validity: legalValidity(recorded) });
});
router.patch('/folders/:id', (req, res) => {
const id = Number(req.params.id);
const folder = db.prepare('SELECT * FROM folders WHERE id = ?').get(id);
if (!folder) return res.status(404).json({ error: 'Folder not found.' });
const { name, parent_id, recorded_date } = req.body || {};
if (recorded_date !== undefined) {
const typed = String(recorded_date || '').trim();
if (!typed) return res.status(400).json({ error: 'A recorded date is required for the folder.' });
const recorded = parseInputDate(typed);
if (!recorded) {
return res.status(400).json({ error: 'Recorded date must be a valid date in DD-MM-YYYY format.' });
}
db.prepare('UPDATE folders SET recorded_date = ? WHERE id = ?').run(recorded, id);
log(req, 'FOLDER_DATE_SET', 'Data Management',
`Folder ${id} "${folder.name}" recorded date set to ${recorded} (valid until ${legalValidity(recorded)})`);
}
if (name !== undefined) {
db.prepare('UPDATE folders SET name = ? WHERE id = ?').run(name.trim(), id);
log(req, 'FOLDER_RENAME', 'Data Management', `Renamed folder ${id} "${folder.name}" -> "${name}"`);
}
if (parent_id !== undefined) {
if (Number(parent_id) === id) return res.status(400).json({ error: 'A folder cannot contain itself.' });
// prevent moving into own descendant
if (isDescendant(id, Number(parent_id))) {
return res.status(400).json({ error: 'Cannot move a folder into one of its own sub-folders.' });
}
db.prepare('UPDATE folders SET parent_id = ? WHERE id = ?').run(parent_id || null, id);
log(req, 'FOLDER_MOVE', 'Data Management', `Moved folder ${id} "${folder.name}" to parent ${parent_id || 'root'}`);
}
res.json({ ok: true });
});
router.delete('/folders/:id', (req, res) => {
const id = Number(req.params.id);
const folder = db.prepare('SELECT * FROM folders WHERE id = ?').get(id);
if (!folder) return res.status(404).json({ error: 'Folder not found.' });
// gather files under this folder subtree to remove from disk
const ids = collectSubtree(id);
const files = db.prepare(
`SELECT stored_name FROM files WHERE folder_id IN (${ids.map(() => '?').join(',')})`
).all(...ids);
db.prepare('DELETE FROM folders WHERE id = ?').run(id); // cascade removes files rows
for (const f of files) safeUnlink(f.stored_name);
log(req, 'FOLDER_DELETE', 'Data Management', `Deleted folder ${id} "${folder.name}" and ${files.length} file(s)`);
res.json({ ok: true });
});
// ---- Files ------------------------------------------------------------
router.post('/files', upload.array('files'), (req, res) => {
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
const inserted = [];
const stmt = db.prepare(
`INSERT INTO files (name, stored_name, size, mime, folder_id, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)`
);
for (const f of req.files || []) {
const info = stmt.run(f.originalname, f.filename, f.size, f.mimetype, folderId, req.session.user.id);
inserted.push({ id: info.lastInsertRowid, name: f.originalname });
log(req, 'FILE_UPLOAD', 'Data Management', `Uploaded "${f.originalname}" (${f.size} bytes) to folder ${folderId || 'root'}`);
}
res.json({ ok: true, files: inserted });
});
router.patch('/files/:id', (req, res) => {
const id = Number(req.params.id);
const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id);
if (!file) return res.status(404).json({ error: 'File not found.' });
const { name, folder_id } = req.body || {};
if (name !== undefined) {
db.prepare('UPDATE files SET name = ? WHERE id = ?').run(name.trim(), id);
log(req, 'FILE_RENAME', 'Data Management', `Renamed file ${id} "${file.name}" -> "${name}"`);
}
if (folder_id !== undefined) {
db.prepare('UPDATE files SET folder_id = ? WHERE id = ?').run(folder_id || null, id);
log(req, 'FILE_MOVE', 'Data Management', `Moved file ${id} "${file.name}" to folder ${folder_id || 'root'}`);
}
res.json({ ok: true });
});
router.delete('/files/:id', (req, res) => {
const id = Number(req.params.id);
const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id);
if (!file) return res.status(404).json({ error: 'File not found.' });
db.prepare('DELETE FROM files WHERE id = ?').run(id);
safeUnlink(file.stored_name);
log(req, 'FILE_DELETE', 'Data Management', `Deleted file ${id} "${file.name}"`);
res.json({ ok: true });
});
// ---- helpers ----------------------------------------------------------
function childFolderIds(parentId) {
return db.prepare('SELECT id FROM folders WHERE parent_id IS ?').all(parentId).map((r) => r.id);
}
function collectSubtree(rootId) {
const out = [rootId];
const queue = [rootId];
while (queue.length) {
const cur = queue.shift();
for (const cid of childFolderIds(cur)) { out.push(cid); queue.push(cid); }
}
return out;
}
function isDescendant(folderId, candidateParentId) {
if (!candidateParentId) return false;
return collectSubtree(folderId).includes(candidateParentId);
}
function safeUnlink(storedName) {
try { fs.unlinkSync(path.join(UPLOAD_DIR, storedName)); } catch (_) { /* ignore */ }
}
module.exports = router;