Files
Infosec/routes/data.js
T
2026-09-13 20:10:17 +01:00

370 lines
15 KiB
JavaScript

'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;