v_2.3
This commit is contained in:
+178
@@ -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
@@ -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;
|
||||
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user