This commit is contained in:
jpmvaz
2026-09-13 20:10:17 +01:00
parent 9b5cc30fb4
commit b192fb3f17
482 changed files with 0 additions and 48205 deletions
+178
View File
@@ -0,0 +1,178 @@
'use strict';
const express = require('express');
const bcrypt = require('bcryptjs');
const speakeasy = require('speakeasy');
const qrcode = require('qrcode');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
const AVATAR_DIR = process.env.AVATAR_DIR || path.join(__dirname, '..', 'avatars');
fs.mkdirSync(AVATAR_DIR, { recursive: true });
const ALLOWED_AVATAR = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
const avatarUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, AVATAR_DIR),
filename: (req, file, cb) =>
cb(null, `u${req.session.user.id}_${crypto.randomBytes(6).toString('hex')}${ALLOWED_AVATAR[file.mimetype] || ''}`),
}),
fileFilter: (req, file, cb) => cb(null, !!ALLOWED_AVATAR[file.mimetype]),
limits: { fileSize: 4 * 1024 * 1024 }, // 4 MB
});
function publicUser(u) {
return {
id: u.id, username: u.username, email: u.email, role: u.role,
mfa_enabled: !!u.mfa_enabled,
avatar: u.avatar ? `/api/auth/avatar/${u.id}?v=${encodeURIComponent(u.avatar)}` : null,
};
}
// Step 1: username + password. If MFA on, respond with mfa_required.
router.post('/login', (req, res) => {
const { username, password, token } = req.body || {};
const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
log(req, 'LOGIN_FAILED', 'Login', `Failed login for "${username}"`);
return res.status(401).json({ error: 'Invalid username or password.' });
}
if (user.mfa_enabled) {
if (!token) {
return res.json({ mfa_required: true });
}
const ok = speakeasy.totp.verify({
secret: user.mfa_secret,
encoding: 'base32',
token: String(token).replace(/\s/g, ''),
window: 1,
});
if (!ok) {
log(req, 'MFA_FAILED', 'Login', `Bad MFA code for "${user.username}"`);
return res.status(401).json({ error: 'Invalid authentication code.' });
}
}
req.session.user = publicUser(user);
log(req, 'LOGIN', 'Login', `${user.username} signed in`);
res.json({ ok: true, user: req.session.user });
});
router.post('/logout', requireAuth, (req, res) => {
log(req, 'LOGOUT', 'Login', `${req.session.user.username} signed out`);
req.session.destroy(() => res.json({ ok: true }));
});
router.get('/me', requireAuth, (req, res) => {
const fresh = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.user.id);
req.session.user = publicUser(fresh);
res.json({ user: req.session.user });
});
// --- MFA ---------------------------------------------------------------
// Generate a secret + QR to scan. Secret is held on session until verified.
router.post('/mfa/setup', requireAuth, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `Infosec (${req.session.user.username})`,
});
req.session.pending_mfa = secret.base32;
const qr = await qrcode.toDataURL(secret.otpauth_url);
res.json({ otpauth_url: secret.otpauth_url, qr, base32: secret.base32 });
});
// Verify the first code and turn MFA on.
router.post('/mfa/enable', requireAuth, (req, res) => {
const { token } = req.body || {};
const pending = req.session.pending_mfa;
if (!pending) return res.status(400).json({ error: 'Start MFA setup first.' });
const ok = speakeasy.totp.verify({
secret: pending, encoding: 'base32',
token: String(token || '').replace(/\s/g, ''), window: 1,
});
if (!ok) return res.status(400).json({ error: 'Code did not match. Try again.' });
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?')
.run(pending, req.session.user.id);
delete req.session.pending_mfa;
req.session.user.mfa_enabled = true;
log(req, 'MFA_ENABLED', 'Account', `${req.session.user.username} enabled MFA`);
res.json({ ok: true });
});
router.post('/mfa/disable', requireAuth, (req, res) => {
const { password } = req.body || {};
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.user.id);
if (!bcrypt.compareSync(password || '', user.password_hash)) {
return res.status(401).json({ error: 'Password incorrect.' });
}
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?')
.run(user.id);
req.session.user.mfa_enabled = false;
log(req, 'MFA_DISABLED', 'Account', `${user.username} disabled MFA`);
res.json({ ok: true });
});
// ---- Profile picture ---------------------------------------------------
// Upload (or replace) the signed-in user's picture.
function avatarUploadSafe(req, res, next) {
avatarUpload.single('avatar')(req, res, (err) => {
if (err) {
const msg = err.code === 'LIMIT_FILE_SIZE'
? 'That image is larger than the 4 MB limit.'
: 'Please choose a PNG, JPEG, WebP or GIF image (max 4 MB).';
return res.status(400).json({ error: msg });
}
next();
});
}
router.post('/avatar', requireAuth, avatarUploadSafe, (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Please choose a PNG, JPEG, WebP or GIF image (max 4 MB).' });
}
const id = req.session.user.id;
const prev = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id);
db.prepare('UPDATE users SET avatar = ?, avatar_mime = ? WHERE id = ?')
.run(req.file.filename, req.file.mimetype, id);
// Remove the old file so avatars do not accumulate on disk.
if (prev && prev.avatar && prev.avatar !== req.file.filename) {
try { fs.unlinkSync(path.join(AVATAR_DIR, prev.avatar)); } catch (_) { /* already gone */ }
}
req.session.user = publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id));
log(req, 'AVATAR_UPDATED', 'My Account', `${req.session.user.username} updated their profile picture`);
res.json({ ok: true, avatar: req.session.user.avatar });
});
// Remove the signed-in user's picture.
router.delete('/avatar', requireAuth, (req, res) => {
const id = req.session.user.id;
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id);
if (row && row.avatar) {
try { fs.unlinkSync(path.join(AVATAR_DIR, row.avatar)); } catch (_) { /* already gone */ }
}
db.prepare('UPDATE users SET avatar = NULL, avatar_mime = NULL WHERE id = ?').run(id);
req.session.user = publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id));
log(req, 'AVATAR_REMOVED', 'My Account', `${req.session.user.username} removed their profile picture`);
res.json({ ok: true });
});
// Serve a user's picture. Any signed-in user may view another's (e.g. admins
// looking at the user list), but never without a session.
router.get('/avatar/:id', requireAuth, (req, res) => {
const row = db.prepare('SELECT avatar, avatar_mime FROM users WHERE id = ?').get(Number(req.params.id));
if (!row || !row.avatar) return res.status(404).send('No picture.');
const file = path.join(AVATAR_DIR, row.avatar);
if (!fs.existsSync(file)) return res.status(404).send('No picture.');
if (row.avatar_mime) res.type(row.avatar_mime);
res.setHeader('Cache-Control', 'private, max-age=300');
res.sendFile(file);
});
module.exports = router;
+369
View File
@@ -0,0 +1,369 @@
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const archiver = null; // folder downloads are zipped on the fly if available (optional)
const { db } = require('../db');
const { log } = require('../lib/audit');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { sendMail, adminEmails, getMailConfig } = require('../lib/mailer');
const { buildEmail } = require('../lib/templates');
const { withValidity, DURATIONS, expiryFor, nowIso, toDisplayDate } = require('../lib/dates');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
// ---- Access helpers ---------------------------------------------------
// Access is granted per FOLDER. Approval on a folder implies access to
// everything inside it (sub-folders and files), so users never have to make a
// second request for the contents of a folder they can already open.
const LIVE_APPROVAL = `status = 'approved'
AND (access_expires_at IS NULL OR access_expires_at > datetime('now'))`;
function approvedFolderIds(userId) {
return db.prepare(
`SELECT DISTINCT target_id FROM access_requests
WHERE user_id = ? AND target_type = 'folder' AND ${LIVE_APPROVAL}`
).all(userId).map((r) => r.target_id);
}
/**
* The live approval covering a folder, if any: either on the folder itself or
* on an ancestor (access to a folder cascades to everything inside it).
* Used to show the countdown against each folder.
*/
function approvalForFolder(userId, folderId) {
const chain = [];
let cur = folderId;
const guard = new Set();
while (cur && !guard.has(cur)) {
guard.add(cur);
chain.push(cur);
const row = db.prepare('SELECT parent_id FROM folders WHERE id = ?').get(cur);
cur = row ? row.parent_id : null;
}
if (!chain.length) return null;
const placeholders = chain.map(() => '?').join(',');
return db.prepare(
`SELECT target_id, access_duration, access_expires_at, decided_at
FROM access_requests
WHERE user_id = ? AND target_type = 'folder' AND target_id IN (${placeholders})
AND ${LIVE_APPROVAL}
ORDER BY (access_expires_at IS NULL) DESC, access_expires_at DESC
LIMIT 1`
).get(userId, ...chain) || null;
}
/** Expand a set of folder ids to include every descendant folder. */
function withDescendants(ids) {
const rows = db.prepare('SELECT id, parent_id FROM folders').all();
const children = new Map();
for (const f of rows) {
const k = f.parent_id || 0;
if (!children.has(k)) children.set(k, []);
children.get(k).push(f.id);
}
const out = new Set();
const stack = [...ids];
while (stack.length) {
const id = stack.pop();
if (out.has(id)) continue;
out.add(id);
for (const c of children.get(id) || []) stack.push(c);
}
return out;
}
/** Folder ids this user may open (empty for none). Admins may open all. */
function accessibleFolderIds(user) {
if (user.role === 'admin') {
return new Set(db.prepare('SELECT id FROM folders').all().map((f) => f.id));
}
return withDescendants(approvedFolderIds(user.id));
}
function requestStateFor(userId) {
const reqs = db.prepare(
`SELECT target_type, target_id, status FROM access_requests
WHERE user_id = ? ORDER BY created_at DESC`
).all(userId);
const state = {};
for (const r of reqs) {
const k = `${r.target_type}:${r.target_id}`;
if (!state[k]) state[k] = r.status; // most recent wins
}
return state;
}
// ---- Browse (any signed-in user) -------------------------------------
router.get('/tree', requireAuth, (req, res) => {
const user = req.session.user;
const isAdmin = user.role === 'admin';
const accessible = accessibleFolderIds(user);
const state = requestStateFor(user.id);
const allFolders = db.prepare(
'SELECT id, name, parent_id, recorded_date FROM folders ORDER BY name'
).all().map(withValidity);
const allFiles = db.prepare(
'SELECT id, name, size, mime, folder_id, created_at FROM files ORDER BY name'
).all();
// A folder is listed when it sits at the root (so it can be requested) or
// when its parent is already accessible. Its CONTENTS are only listed when
// the folder itself is accessible.
const folders = allFolders
.filter((f) => isAdmin || !f.parent_id || accessible.has(f.parent_id))
.map((f) => {
const canOpen = isAdmin || accessible.has(f.id);
// Countdown information for the folder. Administrators always have
// access and are never on a timer.
let access = { state: 'denied', expires_at: null, duration: null };
if (isAdmin) {
access = { state: 'admin', expires_at: null, duration: 'forever' };
} else if (canOpen) {
const appr = approvalForFolder(user.id, f.id);
access = appr
? {
state: appr.access_expires_at ? 'timed' : 'permanent',
expires_at: appr.access_expires_at,
duration: appr.access_duration,
granted_on: appr.target_id === f.id ? null : appr.target_id,
}
: { state: 'permanent', expires_at: null, duration: 'forever' };
}
return { ...f, accessible: canOpen, access };
});
const files = allFiles
.filter((f) => {
if (isAdmin) return true;
if (!f.folder_id) return true; // loose files at the root
return accessible.has(f.folder_id); // inside an approved folder
})
.map((f) => ({
...f,
// Files inside an accessible folder are downloadable outright; a loose
// root-level file still needs its own approval.
accessible: isAdmin
|| (f.folder_id ? accessible.has(f.folder_id) : state[`file:${f.id}`] === 'approved'),
}));
// The browser counts down against server time, so a wrong clock on the
// user's machine cannot make access look longer or shorter than it is.
res.json({ folders, files, requestState: state, server_time: nowIso() });
});
// ---- Request access ---------------------------------------------------
router.post('/request', requireAuth, async (req, res) => {
const { target_type, target_id } = req.body || {};
if (!['file', 'folder'].includes(target_type)) {
return res.status(400).json({ error: 'Invalid target type.' });
}
const row = target_type === 'file'
? db.prepare('SELECT id, name, folder_id FROM files WHERE id = ?').get(target_id)
: db.prepare('SELECT id, name, parent_id FROM folders WHERE id = ?').get(target_id);
if (!row) return res.status(404).json({ error: 'Item not found.' });
const user = req.session.user;
// If the item is already covered by an approved folder, there is nothing to
// request — access to a folder implies access to everything inside it.
const accessible = accessibleFolderIds(user);
const covered = target_type === 'folder'
? accessible.has(row.id)
: (row.folder_id ? accessible.has(row.folder_id) : false);
if (covered) {
return res.status(400).json({ error: 'You already have access to this item.' });
}
// Don't stack duplicate pending requests for the same target.
const pending = db.prepare(
`SELECT 1 FROM access_requests
WHERE user_id = ? AND target_type = ? AND target_id = ? AND status = 'pending' LIMIT 1`
).get(user.id, target_type, target_id);
if (pending) {
return res.status(400).json({ error: 'You already have a pending request for this item.' });
}
const info = db.prepare(
`INSERT INTO access_requests (user_id, target_type, target_id, target_name)
VALUES (?, ?, ?, ?)`
).run(user.id, target_type, target_id, row.name);
log(req, 'ACCESS_REQUEST', 'View Data',
`${user.username} requested ${target_type} "${row.name}" (request ${info.lastInsertRowid})`);
// ---- Notify EVERY administrator that a request is awaiting approval ----
const requestId = info.lastInsertRowid;
const pendingCount = db.prepare("SELECT COUNT(*) c FROM access_requests WHERE status = 'pending'").get().c;
// Build a clickable link when a base URL is configured; fall back to the
// request's own origin so the link still works in a typical deployment.
const cfg = getMailConfig();
const origin = cfg.base_url || `${req.protocol}://${req.get('host') || 'localhost'}`;
const vars = {
username: user.username, email: user.email,
target_type, target_name: row.name,
created_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
request_id: requestId,
pending_count: pendingCount,
portal_url: origin,
approvals_url: `${origin}/data-management.html`,
};
const mail = buildEmail('request_to_admin', vars);
const recipients = adminEmails();
if (!recipients.length) {
// Nobody to tell — record it rather than failing silently.
log(req, 'MAIL_ERROR', 'View Data',
`No administrator email addresses available to notify about request ${requestId}`);
} else {
try {
const r = await sendMail({
to: recipients.join(','), subject: mail.subject, html: mail.html,
context: `Access request for ${target_type} "${row.name}"`, actor: user.username,
});
log(req, 'MAIL_SENT', 'View Data',
`Pending-approval notice sent to ${recipients.length} administrator(s) for request ${requestId} ` +
`${r.delivered ? '(delivered)' : '(queued — no mail server configured)'}`);
} catch (e) {
// A mail failure must never lose the request itself.
log(req, 'MAIL_ERROR', 'View Data', `Admin notify failed for request ${requestId}: ${e.message}`);
}
}
res.json({ ok: true, request_id: requestId });
});
// ---- Admin: list + decide --------------------------------------------
router.get('/requests', requireAuth, requireAdmin, (req, res) => {
const rows = db.prepare(
`SELECT ar.*, u.username, u.email
FROM access_requests ar JOIN users u ON u.id = ar.user_id
ORDER BY ar.created_at DESC`
).all();
res.json({ requests: rows });
});
async function decide(req, res, status) {
const id = Number(req.params.id);
const ar = db.prepare(
`SELECT ar.*, u.username, u.email FROM access_requests ar
JOIN users u ON u.id = ar.user_id WHERE ar.id = ?`
).get(id);
if (!ar) return res.status(404).json({ error: 'Request not found.' });
if (ar.status !== 'pending') return res.status(400).json({ error: 'Request already decided.' });
// How long the access lasts, measured from this moment.
let duration = null;
let expiresAt = null;
if (status === 'approved') {
duration = String((req.body && req.body.duration) || '').trim();
if (!DURATIONS[duration]) {
return res.status(400).json({
error: 'Choose how long the access is valid: 24h, 15d, 30d or forever.',
});
}
expiresAt = expiryFor(duration); // null for "forever"
}
db.prepare(
`UPDATE access_requests
SET status = ?, decided_by = ?, decided_at = CURRENT_TIMESTAMP,
access_duration = ?, access_expires_at = ?
WHERE id = ?`
).run(status, req.session.user.id, duration, expiresAt, id);
const validity = status === 'approved'
? (expiresAt ? `${DURATIONS[duration].label} (until ${expiresAt} UTC)` : 'Valid forever')
: '';
log(req, status === 'approved' ? 'REQUEST_APPROVED' : 'REQUEST_DENIED', 'Data Management',
`${req.session.user.username} ${status} request ${id} for ${ar.username} ` +
`(${ar.target_type} "${ar.target_name}")${validity ? ' — ' + validity : ''}`);
const key = status === 'approved' ? 'approval_to_user' : 'denial_to_user';
const mail = buildEmail(key, {
username: ar.username, email: ar.email,
target_type: ar.target_type, target_name: ar.target_name,
validity: status === 'approved' ? (DURATIONS[duration] || {}).label || '' : '',
expires_at: expiresAt ? toDisplayDate(expiresAt) : 'no expiry',
});
try {
const r = await sendMail({
to: ar.email, subject: mail.subject, html: mail.html,
context: `Request ${status}: ${ar.target_type} "${ar.target_name}"`,
actor: req.session.user.username,
});
log(req, 'MAIL_SENT', 'Data Management',
`${status} notice to ${ar.email} ${r.delivered ? 'delivered' : 'queued (no SMTP)'}`);
} catch (e) {
log(req, 'MAIL_ERROR', 'Data Management', `Decision mail failed: ${e.message}`);
}
res.json({ ok: true });
}
router.post('/requests/:id/approve', requireAuth, requireAdmin, (req, res) => decide(req, res, 'approved'));
router.post('/requests/:id/deny', requireAuth, requireAdmin, (req, res) => decide(req, res, 'denied'));
// ---- Download (must be approved, or admin) ---------------------------
function hasApproval(userId, type, id) {
const row = db.prepare(
`SELECT 1 FROM access_requests
WHERE user_id = ? AND target_type = ? AND target_id = ? AND ${LIVE_APPROVAL}
LIMIT 1`
).get(userId, type, id);
return !!row;
}
router.get('/download/file/:id', requireAuth, (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).send('File not found.');
const user = req.session.user;
const isAdmin = user.role === 'admin';
// Access comes either from approval on the containing folder (which cascades
// to everything inside it) or, for a loose root-level file, on the file.
const viaFolder = file.folder_id ? accessibleFolderIds(user).has(file.folder_id) : false;
const viaFile = hasApproval(user.id, 'file', id);
if (!isAdmin && !viaFolder && !viaFile) {
return res.status(403).send('You do not have approved access to this file.');
}
log(req, 'FILE_DOWNLOAD', 'View Data', `${user.username} downloaded "${file.name}"`);
res.download(path.join(UPLOAD_DIR, file.stored_name), file.name);
});
// ---- Inline view (same access rules as download, rendered not downloaded) ----
// Opens a file in the browser instead of downloading it. Uploaded files can be
// anything — including HTML — so the response is hard sandboxed: the CSP
// `sandbox` directive makes the browser treat it as a unique, script-disabled
// origin that cannot run scripts or reach this app's session, cookies or DOM,
// and `nosniff` stops MIME guessing. Embedded (data:) images and inline styles
// are still allowed so created documents and timelines render fully.
router.get('/view/file/:id', requireAuth, (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).send('File not found.');
const user = req.session.user;
const isAdmin = user.role === 'admin';
const viaFolder = file.folder_id ? accessibleFolderIds(user).has(file.folder_id) : false;
const viaFile = hasApproval(user.id, 'file', id);
if (!isAdmin && !viaFolder && !viaFile) {
return res.status(403).send('You do not have approved access to this file.');
}
const full = path.join(UPLOAD_DIR, file.stored_name);
if (!fs.existsSync(full)) return res.status(404).send('File is missing from storage.');
const safeName = String(file.name).replace(/[^\w.\- ]+/g, '_');
res.setHeader('Content-Security-Policy',
"sandbox; default-src 'none'; img-src data: blob:; media-src data: blob:; style-src 'unsafe-inline'; font-src data:");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Content-Type', file.mime || 'application/octet-stream');
res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
log(req, 'FILE_VIEW', 'View Data', `${user.username} opened "${file.name}"`);
fs.createReadStream(full).pipe(res);
});
module.exports = router;
+225
View File
@@ -0,0 +1,225 @@
'use strict';
/**
* Data Creation — build files from the Data Management page and save them into
* an existing folder (or root). Two kinds of file can be produced:
*
* • Document — free text and images, in the order the author arranges them.
* • Timeline — main events (Name / Date / Time / Additional Data) with
* optional branches off any event, sharing the same four fields.
*
* Both are rendered to a single, self-contained HTML file (images embedded as
* data URIs, no scripts) and stored via the SAME `files` table and `uploads/`
* directory the rest of the app already uses. Nothing new is added to the
* database schema, so this feature deploys over an existing install untouched.
*/
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 { 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 });
// Images for a document are sent as multipart files and embedded into the
// output, so they are held in memory just long enough to base64-encode them.
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1024 * 1024 * 25, files: 60 }, // 25MB per image, 60 max
});
router.use(requireAuth, requireAdmin);
// ---- helpers ----------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
// YYYY-MM-DD -> DD-MM-YYYY for display; anything else is shown as typed.
function fmtDate(v) {
const m = String(v || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : String(v || '').trim();
}
function folderExists(id) {
if (id === null) return true; // root
return !!db.prepare('SELECT 1 FROM folders WHERE id = ?').get(id);
}
// Ensure the display name ends in .html so it is recognisable once saved.
function normaliseName(name) {
const n = String(name || '').trim();
if (!n) return '';
return /\.html?$/i.test(n) ? n : `${n}.html`;
}
const PAGE_STYLE = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 840px; margin: 40px auto; padding: 0 22px; color: #16242b; line-height: 1.6;
background: #fff; -webkit-text-size-adjust: 100%; }
h1.doc-title { font-size: 26px; margin: 0 0 6px; letter-spacing: -.01em; }
.doc-sub { color: #6b7b83; font-size: 13px; margin: 0 0 26px; }
.block { margin: 0 0 20px; }
.block p { white-space: pre-wrap; margin: 0; }
figure { margin: 0 0 20px; }
figure img { max-width: 100%; height: auto; border-radius: 10px; border: 1px solid #e6ebed; display: block; }
figcaption { color: #6b7b83; font-size: 13px; margin-top: 8px; }
.doc-foot { color: #9aa7ad; font-size: 12px; border-top: 1px solid #e6ebed; margin-top: 40px; padding-top: 14px; }
.tl { list-style: none; margin: 8px 0 0; padding: 0 0 0 26px; border-left: 3px solid #2aa596; }
.tl > li { position: relative; margin: 0 0 26px; }
.tl > li::before { content: ""; position: absolute; left: -34px; top: 3px; width: 13px; height: 13px;
background: #2aa596; border: 3px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px #2aa596; }
.ev-name { font-weight: 700; font-size: 16px; }
.ev-when { color: #2a7d73; font-size: 13px; font-weight: 600; margin-top: 2px; }
.ev-data { white-space: pre-wrap; margin-top: 6px; color: #33454d; }
.branches { list-style: none; margin: 14px 0 0; padding: 0 0 0 22px; border-left: 2px dashed #c7d2d6; }
.branches > li { position: relative; margin: 0 0 16px; }
.branches > li::before { content: ""; position: absolute; left: -29px; top: 4px; width: 10px; height: 10px;
background: #fff; border: 2px solid #7fb8b0; border-radius: 50%; }
.branches .ev-name { font-size: 14.5px; }
@media print { body { margin: 0; } }
`;
function pageShell(title, inner) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<style>${PAGE_STYLE}</style>
</head>
<body>
${inner}
<div class="doc-foot">Created with Infosec · Data Creation — ${esc(fmtDate(new Date().toISOString().slice(0, 10)))}</div>
</body>
</html>`;
}
function buildDocumentHtml(title, blocks, images) {
const head = title ? `<h1 class="doc-title">${esc(title)}</h1>` : '';
const body = blocks.map((b) => {
if (b.type === 'image') {
const img = images[b.img];
if (!img) return '';
const dataUri = `data:${img.mimetype};base64,${img.buffer.toString('base64')}`;
const cap = b.caption ? `<figcaption>${esc(b.caption)}</figcaption>` : '';
return `<figure>\n <img src="${dataUri}" alt="${esc(b.caption || '')}">\n ${cap}\n</figure>`;
}
// text
return `<div class="block"><p>${esc(b.text)}</p></div>`;
}).join('\n');
return pageShell(title || 'Document', `${head}\n${body}`);
}
function renderEvent(e) {
const when = [e.date ? fmtDate(e.date) : '', e.time ? String(e.time).trim() : '']
.filter(Boolean).join(' · ');
const whenLine = when ? `<div class="ev-when">${esc(when)}</div>` : '';
const dataLine = (e.data && String(e.data).trim())
? `<div class="ev-data">${esc(e.data)}</div>` : '';
const branches = (Array.isArray(e.branches) && e.branches.length)
? `<ul class="branches">${e.branches.map((b) => `<li>${renderEvent(b)}</li>`).join('')}</ul>`
: '';
return `<div class="ev-name">${esc(e.name)}</div>${whenLine}${dataLine}${branches}`;
}
function buildTimelineHtml(title, events) {
const head = title ? `<h1 class="doc-title">${esc(title)}</h1>` : '';
const items = events.map((ev) => `<li>${renderEvent(ev)}</li>`).join('\n');
return pageShell(title || 'Timeline', `${head}\n<ul class="tl">\n${items}\n</ul>`);
}
// Persist a generated HTML string as a file record + on-disk file.
function saveHtmlFile(req, res, html, displayName, folderId) {
const buf = Buffer.from(html, 'utf8');
const storedName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.html`;
fs.writeFileSync(path.join(UPLOAD_DIR, storedName), buf);
const info = db.prepare(
`INSERT INTO files (name, stored_name, size, mime, folder_id, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)`
).run(displayName, storedName, buf.length, 'text/html', folderId, req.session.user.id);
log(req, 'FILE_CREATE', 'Data Management',
`Created file "${displayName}" (${buf.length} bytes) in folder ${folderId || 'root'} via Data Creation`);
return res.json({ ok: true, file: { id: info.lastInsertRowid, name: displayName, folder_id: folderId } });
}
// ---- Document ---------------------------------------------------------
router.post('/document', upload.array('images'), (req, res) => {
let spec;
try { spec = JSON.parse(req.body.spec || '[]'); }
catch (_) { return res.status(400).json({ error: 'Invalid document content.' }); }
if (!Array.isArray(spec) || spec.length === 0) {
return res.status(400).json({ error: 'Add at least one text or image block.' });
}
const images = req.files || [];
for (const b of spec) {
if (b && b.type === 'image') {
if (typeof b.img !== 'number' || !images[b.img]) {
return res.status(400).json({ error: 'An image block has no image selected.' });
}
if (!/^image\//.test(images[b.img].mimetype || '')) {
return res.status(400).json({ error: 'Only image files can be added to a document.' });
}
} else if (!b || b.type !== 'text' || !String(b.text || '').trim()) {
return res.status(400).json({ error: 'Every text block needs some text.' });
}
}
const title = String(req.body.title || '').trim();
const html = buildDocumentHtml(title, spec, images);
if (String(req.body.preview) === 'true') return res.json({ ok: true, html });
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
if (!folderExists(folderId)) return res.status(400).json({ error: 'Choose an existing folder.' });
const displayName = normaliseName(req.body.name);
if (!displayName) return res.status(400).json({ error: 'A file name is required.' });
return saveHtmlFile(req, res, html, displayName, folderId);
});
// ---- Timeline ---------------------------------------------------------
function cleanEvent(e) {
return {
name: String((e && e.name) || '').trim(),
date: String((e && e.date) || '').trim(),
time: String((e && e.time) || '').trim(),
data: String((e && e.data) || '').trim(),
branches: Array.isArray(e && e.branches) ? e.branches.map(cleanEvent) : [],
};
}
router.post('/timeline', (req, res) => {
const raw = Array.isArray(req.body.events) ? req.body.events : [];
if (raw.length === 0) return res.status(400).json({ error: 'Add at least one event.' });
const events = raw.map(cleanEvent);
const missing = (list) => list.some((e) => !e.name || (e.branches.length && missing(e.branches)));
if (missing(events)) return res.status(400).json({ error: 'Every event and branch needs a Name.' });
const title = String(req.body.title || '').trim();
const html = buildTimelineHtml(title, events);
if (req.body.preview === true || String(req.body.preview) === 'true') {
return res.json({ ok: true, html });
}
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
if (!folderExists(folderId)) return res.status(400).json({ error: 'Choose an existing folder.' });
const displayName = normaliseName(req.body.name);
if (!displayName) return res.status(400).json({ error: 'A file name is required.' });
return saveHtmlFile(req, res, html, displayName, folderId);
});
module.exports = router;
+81
View File
@@ -0,0 +1,81 @@
'use strict';
const express = require('express');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { parseInputDate } = require('../lib/dates');
const router = express.Router();
const PAGE = 'Legislation';
// ---- Read: available to every signed-in user --------------------------
router.get('/', requireAuth, (req, res) => {
const entries = db.prepare(
`SELECT id, reference, title, effective_date, summary, link_url, sort_order, created_at, updated_at
FROM legislation_entries
ORDER BY sort_order DESC, COALESCE(effective_date, '') DESC, id DESC`
).all();
res.json({ entries });
});
// ---- Write: administrators only ---------------------------------------
function clean(body) {
return {
reference: String((body && body.reference) || '').trim(),
title: String((body && body.title) || '').trim(),
effective_date: parseInputDate((body && body.effective_date) || ''),
effective_date_raw: String((body && body.effective_date) || '').trim(),
summary: String((body && body.summary) || '').trim(),
link_url: String((body && body.link_url) || '').trim(),
sort_order: Number.isFinite(Number(body && body.sort_order)) ? Number(body.sort_order) : 0,
};
}
router.post('/', requireAuth, requireAdmin, (req, res) => {
const v = clean(req.body);
if (!v.title) return res.status(400).json({ error: 'A title is required.' });
if (v.effective_date_raw && !v.effective_date) {
return res.status(400).json({ error: 'Effective date must be a valid date in DD-MM-YYYY format.' });
}
const info = db.prepare(
`INSERT INTO legislation_entries (reference, title, effective_date, summary, link_url, sort_order, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(v.reference || null, v.title, v.effective_date || null, v.summary || null,
v.link_url || null, v.sort_order, req.session.user.id);
log(req, 'LEGISLATION_ENTRY_CREATED', 'Data Management', `Added legislation entry "${v.title}"`);
res.json({ ok: true, entry: db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(info.lastInsertRowid) });
});
router.patch('/:id', requireAuth, requireAdmin, (req, res) => {
const id = Number(req.params.id);
const existing = db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Entry not found.' });
const v = clean(req.body);
if (!v.title) return res.status(400).json({ error: 'A title is required.' });
if (v.effective_date_raw && !v.effective_date) {
return res.status(400).json({ error: 'Effective date must be a valid date in DD-MM-YYYY format.' });
}
db.prepare(
`UPDATE legislation_entries
SET reference = ?, title = ?, effective_date = ?, summary = ?, link_url = ?,
sort_order = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
).run(v.reference || null, v.title, v.effective_date || null, v.summary || null,
v.link_url || null, v.sort_order, id);
log(req, 'LEGISLATION_ENTRY_UPDATED', 'Data Management', `Updated legislation entry "${v.title}"`);
res.json({ ok: true, entry: db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(id) });
});
router.delete('/:id', requireAuth, requireAdmin, (req, res) => {
const id = Number(req.params.id);
const existing = db.prepare('SELECT * FROM legislation_entries WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Entry not found.' });
db.prepare('DELETE FROM legislation_entries WHERE id = ?').run(id);
log(req, 'LEGISLATION_ENTRY_DELETED', 'Data Management', `Deleted legislation entry "${existing.title}"`);
res.json({ ok: true });
});
module.exports = router;
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const express = require('express');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { toDisplayDateTime } = require('../lib/dates');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { sendMail, adminEmails } = require('../lib/mailer');
const router = express.Router();
router.use(requireAuth, requireAdmin);
function query({ q, action, page, from, to }) {
const where = [];
const params = [];
if (q) { where.push('(actor LIKE ? OR detail LIKE ?)'); params.push(`%${q}%`, `%${q}%`); }
if (action) { where.push('action = ?'); params.push(action); }
if (page) { where.push('page = ?'); params.push(page); }
if (from) { where.push('ts >= ?'); params.push(from); }
if (to) { where.push('ts <= ?'); params.push(to); }
const sql =
'SELECT id, ts, actor, action, page, detail, ip FROM logs' +
(where.length ? ' WHERE ' + where.join(' AND ') : '') +
' ORDER BY ts DESC, id DESC';
return db.prepare(sql).all(...params);
}
router.get('/', (req, res) => {
const rows = query(req.query);
const actions = db.prepare('SELECT DISTINCT action FROM logs ORDER BY action').all().map((r) => r.action);
const pages = db.prepare('SELECT DISTINCT page FROM logs WHERE page IS NOT NULL ORDER BY page').all().map((r) => r.page);
res.json({ logs: rows.slice(0, 1000), total: rows.length, actions, pages });
});
function toCSV(rows) {
const head = ['id', 'timestamp', 'actor', 'action', 'page', 'detail', 'ip'];
const esc = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;
const lines = [head.join(',')];
for (const r of rows) {
lines.push([r.id, toDisplayDateTime(r.ts), r.actor, r.action, r.page, r.detail, r.ip].map(esc).join(','));
}
return lines.join('\r\n');
}
router.get('/export', (req, res) => {
const rows = query(req.query);
const csv = toCSV(rows);
log(req, 'LOGS_EXPORT', 'Logs', `Exported ${rows.length} log rows as CSV`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="infosec-logs-${Date.now()}.csv"`);
res.send(csv);
});
router.post('/email', async (req, res) => {
const rows = query(req.body || {});
const csv = toCSV(rows);
const to = (req.body && req.body.to) || adminEmails().join(',');
if (!to) return res.status(400).json({ error: 'No recipient. Add an admin email or provide "to".' });
const html =
`<h2>Infosec — audit log export</h2>` +
`<p>${rows.length} log entries are attached as CSV.</p>` +
`<hr style="border:none;border-top:1px solid #d8d8d8">` +
`<p style="color:#8a8a8a;font-size:12px">© 2026 Martinhal IT - Joao Vaz - Version 2.3</p>`;
try {
const r = await sendMail({
to, subject: `Infosec audit logs (${rows.length} entries)`, html,
context: 'Audit log export', actor: req.session.user.username,
attachments: [{ filename: `infosec-logs-${Date.now()}.csv`, content: csv }],
});
log(req, 'LOGS_EMAIL', 'Logs', `Emailed ${rows.length} log rows to ${to} ${r.delivered ? '(delivered)' : '(queued, no SMTP)'}`);
res.json({ ok: true, delivered: r.delivered, count: rows.length });
} catch (e) {
log(req, 'MAIL_ERROR', 'Logs', `Log email failed: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
module.exports = router;
+212
View File
@@ -0,0 +1,212 @@
'use strict';
const express = require('express');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { toDisplayDateTime } = require('../lib/dates');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const {
getMailConfig, saveMailConfig, verifyConfig, verifyImap, sendMail, adminEmails, recordMail,
} = require('../lib/mailer');
const router = express.Router();
router.use(requireAuth, requireAdmin);
const PAGE = 'Data Management';
const MASK = '********';
/** Never send the stored password back to the browser. */
function maskConfig(cfg) {
return {
...cfg,
pass: cfg.pass ? MASK : '',
imap_pass: cfg.imap_pass ? MASK : '',
};
}
// ---- Configuration ----------------------------------------------------
router.get('/config', (req, res) => {
res.json({ config: maskConfig(getMailConfig()) });
});
function readConfig(body, current) {
return {
host: String((body && body.host) || '').trim(),
port: Number(body && body.port) || 587,
secure: !!(body && body.secure),
user: String((body && body.user) || '').trim(),
// An unchanged password field comes back masked — keep what we already have.
pass: (body && body.pass) === MASK ? current.pass : String((body && body.pass) || ''),
from: String((body && body.from) || '').trim(),
base_url: String((body && body.base_url) || '').trim().replace(/\/+$/, ''),
reject_unauthorized: body && body.reject_unauthorized !== undefined
? !!body.reject_unauthorized : true,
// IMAP — used to file a copy of each sent message in the Sent folder.
imap_host: String((body && body.imap_host) || '').trim(),
imap_port: Number(body && body.imap_port) || 993,
imap_secure: body && body.imap_secure !== undefined ? !!body.imap_secure : true,
imap_user: String((body && body.imap_user) || '').trim(),
imap_pass: (body && body.imap_pass) === MASK ? current.imap_pass : String((body && body.imap_pass) || ''),
imap_sent_folder: String((body && body.imap_sent_folder) || '').trim(),
};
}
router.put('/config', (req, res) => {
const current = getMailConfig();
const cfg = readConfig(req.body, current);
if (cfg.host && !cfg.from) {
return res.status(400).json({ error: 'A "from" address is required when a mail server is set.' });
}
if (cfg.port < 1 || cfg.port > 65535) {
return res.status(400).json({ error: 'Port must be between 1 and 65535.' });
}
saveMailConfig(cfg);
log(req, 'MAIL_CONFIG_UPDATED', PAGE,
cfg.host ? `Mail server set to ${cfg.host}:${cfg.port}` : 'Mail server configuration cleared');
res.json({ ok: true, config: maskConfig(getMailConfig()) });
});
// Verify the connection without saving.
router.post('/test', async (req, res) => {
const cfg = readConfig(req.body, getMailConfig());
try {
await verifyConfig(cfg);
log(req, 'MAIL_CONFIG_TESTED', PAGE, `Connection to ${cfg.host}:${cfg.port} succeeded`);
res.json({ ok: true, message: 'Connection successful.' });
} catch (e) {
log(req, 'MAIL_CONFIG_TESTED', PAGE, `Connection to ${cfg.host}:${cfg.port} failed: ${e.message}`);
res.status(400).json({ error: e.message });
}
});
// Verify the IMAP connection used for saving to the Sent folder.
router.post('/test-imap', async (req, res) => {
const cfg = readConfig(req.body, getMailConfig());
try {
const r = await verifyImap(cfg);
log(req, 'MAIL_IMAP_TESTED', PAGE, `IMAP connection to ${cfg.imap_host}:${cfg.imap_port} succeeded`);
res.json({ ok: true, message: `Connected. Sent folder: ${r.sent_folder}` });
} catch (e) {
log(req, 'MAIL_IMAP_TESTED', PAGE, `IMAP connection to ${cfg.imap_host}:${cfg.imap_port} failed: ${e.message}`);
res.status(400).json({ error: e.message });
}
});
// Send a real test message using the SAVED configuration.
router.post('/test-send', async (req, res) => {
const to = String((req.body && req.body.to) || '').trim() || req.session.user.email;
try {
const r = await sendMail({
to,
subject: 'Infosec test message',
html: '<p>This is a test message from Infosec. If you received it, your mail server settings are working.</p>',
context: 'Test message',
actor: req.session.user.username,
});
log(req, 'MAIL_TEST_SENT', PAGE, `Test message to ${to} ${r.delivered ? 'delivered' : 'queued (no mail server)'}`);
res.json({ ok: true, delivered: r.delivered });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ---- Mail log ---------------------------------------------------------
function queryLog(q) {
const where = [];
const args = [];
if (q.q) {
where.push('(to_addr LIKE ? OR from_addr LIKE ? OR subject LIKE ? OR body_preview LIKE ?)');
const like = `%${q.q}%`;
args.push(like, like, like, like);
}
if (q.direction) { where.push('direction = ?'); args.push(q.direction); }
if (q.status) { where.push('status = ?'); args.push(q.status); }
if (q.from) { where.push('ts >= ?'); args.push(q.from); }
if (q.to) { where.push('ts <= ?'); args.push(q.to + ' 23:59:59'); }
const sql = where.length ? ' WHERE ' + where.join(' AND ') : '';
const rows = db.prepare(`SELECT * FROM mail_log${sql} ORDER BY ts DESC, id DESC LIMIT 1000`).all(...args);
const total = db.prepare(`SELECT COUNT(*) c FROM mail_log${sql}`).get(...args).c;
return { rows, total };
}
router.get('/log', (req, res) => {
const { rows, total } = queryLog(req.query);
res.json({
entries: rows,
total,
statuses: db.prepare('SELECT DISTINCT status FROM mail_log ORDER BY status').all().map((r) => r.status),
directions: db.prepare('SELECT DISTINCT direction FROM mail_log ORDER BY direction').all().map((r) => r.direction),
});
});
function toCsv(rows) {
const head = ['Time', 'Direction', 'Status', 'From', 'To', 'Subject', 'Attachments', 'Context', 'Actor', 'Error'];
const esc = (v) => {
const s = v === null || v === undefined ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [head.join(',')];
for (const r of rows) {
lines.push([toDisplayDateTime(r.ts), r.direction, r.status, r.from_addr, r.to_addr, r.subject,
r.attachments, r.context, r.actor, r.error].map(esc).join(','));
}
return lines.join('\n');
}
router.get('/log/export', (req, res) => {
const { rows } = queryLog(req.query);
const csv = toCsv(rows);
log(req, 'MAIL_LOG_EXPORT', PAGE, `Exported ${rows.length} mail log entries as CSV`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="infosec-mail-log-${new Date().toISOString().slice(0, 10)}.csv"`);
res.send(csv);
});
router.post('/log/email', async (req, res) => {
const { rows } = queryLog(req.body || {});
const to = String((req.body && req.body.to) || '').trim() || adminEmails().join(',');
if (!to) return res.status(400).json({ error: 'No recipient available.' });
const csv = toCsv(rows);
const html =
`<p>Attached is the Infosec mail log (${rows.length} entries).</p>` +
`<p style="color:#8a8a8a;font-size:12px">© 2026 Martinhal IT - Joao Vaz - Version 2.3</p>`;
try {
const r = await sendMail({
to,
subject: `Infosec mail log (${rows.length} entries)`,
html,
attachments: [{ filename: 'infosec-mail-log.csv', content: csv }],
context: 'Mail log export',
actor: req.session.user.username,
});
log(req, 'MAIL_LOG_EMAILED', PAGE,
`Mail log (${rows.length} entries) to ${to} ${r.delivered ? 'delivered' : 'queued (no mail server)'}`);
res.json({ ok: true, delivered: r.delivered, count: rows.length });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// Record an inbound message. The system has no mailbox of its own, so this
// exists for forwarding/integration: anything posted here appears in the log
// alongside outgoing mail.
router.post('/log/inbound', (req, res) => {
const b = req.body || {};
if (!b.from_addr && !b.subject) {
return res.status(400).json({ error: 'At least a sender or a subject is required.' });
}
recordMail({
direction: 'received',
status: 'delivered',
from_addr: b.from_addr || null,
to_addr: b.to_addr || null,
subject: b.subject || null,
body_preview: String(b.body || '').slice(0, 400) || null,
context: b.context || 'Inbound message',
actor: req.session.user.username,
});
log(req, 'MAIL_INBOUND_RECORDED', PAGE, `Recorded inbound message from ${b.from_addr || 'unknown'}`);
res.json({ ok: true });
});
module.exports = router;
+170
View File
@@ -0,0 +1,170 @@
'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: 'An incident 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: 'Incident 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: 'An incident date is required for the folder.' });
const recorded = parseInputDate(typed);
if (!recorded) {
return res.status(400).json({ error: 'Incident 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}" incident 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;
+125
View File
@@ -0,0 +1,125 @@
'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 { requireAuth, requireAdmin } = require('../middleware/auth');
const { getTemplate, buildEmail, KEYS, DEFAULTS, PLACEHOLDERS } = require('../lib/templates');
const router = express.Router();
const BRAND_DIR = process.env.BRAND_DIR || path.join(__dirname, '..', 'brand');
fs.mkdirSync(BRAND_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, BRAND_DIR),
filename: (req, file, cb) =>
cb(null, `${Date.now()}-${crypto.randomBytes(5).toString('hex')}${path.extname(file.originalname)}`),
});
const okImage = (req, file, cb) =>
cb(null, /^image\//.test(file.mimetype));
const upload = multer({ storage, fileFilter: okImage, limits: { fileSize: 1024 * 1024 * 10 } });
// ---- Email templates --------------------------------------------------
router.get('/templates', requireAuth, requireAdmin, (req, res) => {
const list = KEYS.map((key) => {
const t = getTemplate(key);
return {
key,
...t,
// So the UI can offer "Restore default" when a template has drifted.
is_default: t.subject === DEFAULTS[key].subject && t.body_html === DEFAULTS[key].body_html,
placeholders: PLACEHOLDERS[key] || [],
};
});
res.json({ templates: list, keys: KEYS });
});
// Put a template back to the version that ships with this release. Useful
// after an upgrade, since existing customised templates are never overwritten.
router.post('/templates/:key/restore', requireAuth, requireAdmin, (req, res) => {
const key = req.params.key;
if (!KEYS.includes(key)) return res.status(400).json({ error: 'Unknown template.' });
const d = DEFAULTS[key];
const exists = db.prepare('SELECT key FROM email_templates WHERE key = ?').get(key);
if (exists) {
db.prepare('UPDATE email_templates SET subject = ?, body_html = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?')
.run(d.subject, d.body_html, key);
} else {
db.prepare('INSERT INTO email_templates (key, subject, body_html) VALUES (?, ?, ?)')
.run(key, d.subject, d.body_html);
}
log(req, 'TEMPLATE_RESTORED', 'Data Management', `Restored default email template "${key}"`);
res.json({ ok: true, template: { key, ...d } });
});
router.put('/templates/:key', requireAuth, requireAdmin, (req, res) => {
const key = req.params.key;
if (!KEYS.includes(key)) return res.status(400).json({ error: 'Unknown template.' });
const { subject, body_html } = req.body || {};
if (!subject || !body_html) return res.status(400).json({ error: 'Subject and body are required.' });
const exists = db.prepare('SELECT key FROM email_templates WHERE key = ?').get(key);
if (exists) {
db.prepare('UPDATE email_templates SET subject = ?, body_html = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?')
.run(subject, body_html, key);
} else {
db.prepare('INSERT INTO email_templates (key, subject, body_html) VALUES (?, ?, ?)')
.run(key, subject, body_html);
}
log(req, 'TEMPLATE_UPDATE', 'Data Management', `Updated email template "${key}"`);
res.json({ ok: true });
});
// Live preview with sample data
router.post('/templates/:key/preview', requireAuth, requireAdmin, (req, res) => {
const { subject, body_html } = req.body || {};
// temporarily render provided draft with sample vars, without saving
const vars = {
username: 'jsmith', email: 'jsmith@example.com',
target_type: 'folder', target_name: 'Q4 Financials',
created_at: '2026-07-22 10:30:00',
};
const render = require('../lib/templates').render;
const footer =
'<hr style="margin-top:32px;border:none;border-top:1px solid #d8d8d8">' +
'<p style="color:#8a8a8a;font-size:12px;margin-top:12px">© 2026 Martinhal IT - Joao Vaz - Version 2.3</p>';
res.json({
subject: render(subject || '', vars),
html: render(body_html || '', vars) + footer,
});
});
// ---- Brand images -----------------------------------------------------
router.get('/images', requireAuth, requireAdmin, (req, res) => {
const images = db.prepare('SELECT id, label, stored_name, mime, created_at FROM brand_images ORDER BY created_at DESC').all();
res.json({ images: images.map((i) => ({ ...i, url: `/settings/images/${i.stored_name}` })) });
});
router.post('/images', requireAuth, requireAdmin, upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Please choose an image file.' });
const label = (req.body.label || req.file.originalname).trim();
const info = db.prepare('INSERT INTO brand_images (label, stored_name, mime) VALUES (?, ?, ?)')
.run(label, req.file.filename, req.file.mimetype);
log(req, 'IMAGE_UPLOAD', 'Data Management', `Uploaded brand image "${label}"`);
res.json({ ok: true, id: info.lastInsertRowid, url: `/settings/images/${req.file.filename}` });
});
router.delete('/images/:id', requireAuth, requireAdmin, (req, res) => {
const img = db.prepare('SELECT * FROM brand_images WHERE id = ?').get(Number(req.params.id));
if (!img) return res.status(404).json({ error: 'Image not found.' });
db.prepare('DELETE FROM brand_images WHERE id = ?').run(img.id);
try { fs.unlinkSync(path.join(BRAND_DIR, img.stored_name)); } catch (_) {}
log(req, 'IMAGE_DELETE', 'Data Management', `Deleted brand image "${img.label}"`);
res.json({ ok: true });
});
// Public-ish serve so email clients / preview can render them (auth still required to browse app)
router.get('/images/:stored', (req, res) => {
const img = db.prepare('SELECT * FROM brand_images WHERE stored_name = ?').get(req.params.stored);
if (!img) return res.status(404).end();
res.sendFile(path.join(BRAND_DIR, img.stored_name));
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
'use strict';
const express = require('express');
const bcrypt = require('bcryptjs');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { normaliseEmail, validateEmail, validateUsername, validatePassword } = require('../lib/validate');
const router = express.Router();
/** True while the instance has no users at all (i.e. needs first-boot setup). */
function needsSetup() {
return db.prepare('SELECT COUNT(*) c FROM users').get().c === 0;
}
// Lets the front-end know whether the wizard should be shown.
router.get('/status', (req, res) => {
res.json({ needs_setup: needsSetup() });
});
// Create the very first administrator. Only ever available while no users exist.
router.post('/', (req, res) => {
if (!needsSetup()) {
return res.status(409).json({ error: 'Setup has already been completed.' });
}
const username = String((req.body && req.body.username) || '').trim();
const email = normaliseEmail(req.body && req.body.email); // mandatory
const password = String((req.body && req.body.password) || '');
const confirm = String((req.body && req.body.confirm) || '');
// --- validation (same rules as the Users tab) -------------------------
const err = validateUsername(username) || validateEmail(email) || validatePassword(password);
if (err) return res.status(400).json({ error: err });
if (password !== confirm) {
return res.status(400).json({ error: 'The two passwords do not match.' });
}
// --- create -----------------------------------------------------------
// Guard against two browsers racing through the wizard simultaneously:
// the transaction re-checks the user count before inserting.
let created;
try {
created = db.transaction(() => {
if (db.prepare('SELECT COUNT(*) c FROM users').get().c !== 0) {
const e = new Error('Setup has already been completed.');
e.status = 409;
throw e;
}
const hash = bcrypt.hashSync(password, 12);
const info = db
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run(username, email, hash, 'admin');
return db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
})();
} catch (err) {
if (err && err.status === 409) return res.status(409).json({ error: err.message });
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ error: 'That username or email is already in use.' });
}
throw err;
}
log(req, 'SETUP_COMPLETED', 'Setup', `First administrator "${created.username}" created`);
// Sign the new administrator straight in — no need to re-type credentials.
req.session.user = {
id: created.id,
username: created.username,
email: created.email,
role: created.role,
mfa_enabled: false,
};
log(req, 'LOGIN', 'Login', `${created.username} signed in`);
res.json({ ok: true, user: req.session.user });
});
module.exports = { router, needsSetup };
+229
View File
@@ -0,0 +1,229 @@
'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;
+157
View File
@@ -0,0 +1,157 @@
'use strict';
const express = require('express');
const bcrypt = require('bcryptjs');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { normaliseEmail, validateEmail, validateUsername, validatePassword } = require('../lib/validate');
const router = express.Router();
router.use(requireAuth, requireAdmin);
const PAGE = 'Data Management';
function publicUser(u) {
const email = (u.email || '').trim();
return {
id: u.id,
username: u.username,
email,
role: u.role,
mfa_enabled: !!u.mfa_enabled,
created_at: u.created_at,
// Databases created before this release could hold accounts with no
// address. Flag them so an administrator can put one in.
email_missing: !email,
};
}
/** Number of administrators, used to stop the last admin being removed. */
function adminCount() {
return db.prepare("SELECT COUNT(*) c FROM users WHERE role = 'admin'").get().c;
}
// ---- list -------------------------------------------------------------
router.get('/', (req, res) => {
const users = db.prepare('SELECT * FROM users ORDER BY role DESC, username').all().map(publicUser);
res.json({ users, missing_email: users.filter((u) => u.email_missing).length });
});
// ---- create -----------------------------------------------------------
router.post('/', (req, res) => {
const body = req.body || {};
const username = body.username === undefined ? '' : String(body.username).trim();
const email = normaliseEmail(body.email); // mandatory
const password = body.password === undefined ? '' : String(body.password);
const role = body.role;
const err = validateUsername(username) || validateEmail(email) || validatePassword(password);
if (err) return res.status(400).json({ error: err });
if (role !== 'admin' && role !== 'user') return res.status(400).json({ error: 'Role must be "admin" or "user".' });
try {
const hash = bcrypt.hashSync(password, 12);
const info = db
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run(username, email, hash, role);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
log(req, 'USER_CREATED', PAGE, `Created ${role} "${user.username}" (${user.email})`);
res.json({ ok: true, user: publicUser(user) });
} catch (e) {
if (String(e.message || '').includes('UNIQUE')) {
return res.status(409).json({ error: 'That username or email is already in use.' });
}
throw e;
}
});
// ---- update (email, role, password reset) -----------------------------
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ error: 'User not found.' });
const { email, role, password } = req.body || {};
// Email is mandatory: when the field is supplied it must be a valid address,
// so an existing account can never be left without one.
if (email !== undefined) {
const emailErr = validateEmail(email);
if (emailErr) return res.status(400).json({ error: emailErr });
}
if (password !== undefined && password !== '') {
const pwErr = validatePassword(password);
if (pwErr) return res.status(400).json({ error: pwErr });
}
// Never let the last administrator demote themselves out of existence.
if (role && role !== user.role && user.role === 'admin' && adminCount() <= 1) {
return res.status(400).json({ error: 'This is the only administrator — promote someone else first.' });
}
if (role && role !== 'admin' && role !== 'user') {
return res.status(400).json({ error: 'Role must be "admin" or "user".' });
}
const changes = [];
try {
const newEmail = normaliseEmail(email);
if (email !== undefined && newEmail !== user.email) {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(newEmail, id);
changes.push(`email → ${newEmail}`);
}
if (role && role !== user.role) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
changes.push(`role → ${role}`);
}
if (password) {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(bcrypt.hashSync(password, 12), id);
changes.push('password reset');
}
} catch (e) {
if (String(e.message || '').includes('UNIQUE')) {
return res.status(409).json({ error: 'That email is already in use.' });
}
throw e;
}
if (changes.length) log(req, 'USER_UPDATED', PAGE, `Updated "${user.username}": ${changes.join(', ')}`);
res.json({ ok: true, user: publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)) });
});
// ---- turn off MFA (recovery: user lost their authenticator) -----------
router.post('/:id/mfa/reset', (req, res) => {
const id = Number(req.params.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ error: 'User not found.' });
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?').run(id);
log(req, 'USER_MFA_RESET', PAGE, `Two-factor authentication reset for "${user.username}"`);
res.json({ ok: true });
});
// ---- delete -----------------------------------------------------------
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ error: 'User not found.' });
if (id === req.session.user.id) {
return res.status(400).json({ error: 'You cannot delete the account you are signed in with.' });
}
if (user.role === 'admin' && adminCount() <= 1) {
return res.status(400).json({ error: 'This is the only administrator and cannot be deleted.' });
}
// Detach the user from everything that references them, in one transaction.
// Content they published is KEPT (ownership is simply cleared) and the audit
// log is untouched — log entries store the username as text, so history
// survives the account being removed.
db.transaction(() => {
db.prepare('DELETE FROM access_requests WHERE user_id = ?').run(id);
db.prepare('UPDATE access_requests SET decided_by = NULL WHERE decided_by = ?').run(id);
db.prepare('UPDATE folders SET created_by = NULL WHERE created_by = ?').run(id);
db.prepare('UPDATE files SET uploaded_by = NULL WHERE uploaded_by = ?').run(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
})();
log(req, 'USER_DELETED', PAGE, `Deleted user "${user.username}" (content and audit history retained)`);
res.json({ ok: true });
});
module.exports = router;
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const express = require('express');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { parseInputDate } = require('../lib/dates');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const router = express.Router();
const PAGE = 'Version Control';
// ---- Read: administrators only ----------------------------------------
router.get('/', requireAuth, requireAdmin, (req, res) => {
const entries = db.prepare(
`SELECT id, version, title, released_on, notes, sort_order, created_at, updated_at
FROM version_entries
ORDER BY sort_order DESC, COALESCE(released_on, '') DESC, id DESC`
).all();
res.json({ entries });
});
// ---- Write: administrators only ---------------------------------------
function clean(body) {
return {
version: String((body && body.version) || '').trim(),
title: String((body && body.title) || '').trim(),
released_on: parseInputDate((body && body.released_on) || ''),
released_on_raw: String((body && body.released_on) || '').trim(),
notes: String((body && body.notes) || '').trim(),
sort_order: Number.isFinite(Number(body && body.sort_order)) ? Number(body.sort_order) : 0,
};
}
router.post('/', requireAuth, requireAdmin, (req, res) => {
const v = clean(req.body);
if (!v.version) return res.status(400).json({ error: 'A version is required.' });
if (v.released_on_raw && !v.released_on) {
return res.status(400).json({ error: 'Release date must be a valid date in DD-MM-YYYY format.' });
}
const info = db.prepare(
`INSERT INTO version_entries (version, title, released_on, notes, sort_order, created_by)
VALUES (?, ?, ?, ?, ?, ?)`
).run(v.version, v.title || null, v.released_on || null, v.notes || null, v.sort_order, req.session.user.id);
log(req, 'VERSION_ENTRY_CREATED', 'Data Management', `Added version entry "${v.version}"`);
res.json({ ok: true, entry: db.prepare('SELECT * FROM version_entries WHERE id = ?').get(info.lastInsertRowid) });
});
router.patch('/:id', requireAuth, requireAdmin, (req, res) => {
const id = Number(req.params.id);
const existing = db.prepare('SELECT * FROM version_entries WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Entry not found.' });
const v = clean(req.body);
if (!v.version) return res.status(400).json({ error: 'A version is required.' });
if (v.released_on_raw && !v.released_on) {
return res.status(400).json({ error: 'Release date must be a valid date in DD-MM-YYYY format.' });
}
db.prepare(
`UPDATE version_entries
SET version = ?, title = ?, released_on = ?, notes = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
).run(v.version, v.title || null, v.released_on || null, v.notes || null, v.sort_order, id);
log(req, 'VERSION_ENTRY_UPDATED', 'Data Management', `Updated version entry "${v.version}"`);
res.json({ ok: true, entry: db.prepare('SELECT * FROM version_entries WHERE id = ?').get(id) });
});
router.delete('/:id', requireAuth, requireAdmin, (req, res) => {
const id = Number(req.params.id);
const existing = db.prepare('SELECT * FROM version_entries WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Entry not found.' });
db.prepare('DELETE FROM version_entries WHERE id = ?').run(id);
log(req, 'VERSION_ENTRY_DELETED', 'Data Management', `Deleted version entry "${existing.version}"`);
res.json({ ok: true });
});
module.exports = router;