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