v_2.3
This commit is contained in:
+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;
|
||||
Reference in New Issue
Block a user