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

226 lines
9.8 KiB
JavaScript

'use strict';
/**
* Data Creation — build files from the Data Management page and save them into
* an existing folder (or root). Two kinds of file can be produced:
*
* • Document — free text and images, in the order the author arranges them.
* • Timeline — main events (Name / Date / Time / Additional Data) with
* optional branches off any event, sharing the same four fields.
*
* Both are rendered to a single, self-contained HTML file (images embedded as
* data URIs, no scripts) and stored via the SAME `files` table and `uploads/`
* directory the rest of the app already uses. Nothing new is added to the
* database schema, so this feature deploys over an existing install untouched.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const multer = require('multer');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Images for a document are sent as multipart files and embedded into the
// output, so they are held in memory just long enough to base64-encode them.
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1024 * 1024 * 25, files: 60 }, // 25MB per image, 60 max
});
router.use(requireAuth, requireAdmin);
// ---- helpers ----------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
// YYYY-MM-DD -> DD-MM-YYYY for display; anything else is shown as typed.
function fmtDate(v) {
const m = String(v || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : String(v || '').trim();
}
function folderExists(id) {
if (id === null) return true; // root
return !!db.prepare('SELECT 1 FROM folders WHERE id = ?').get(id);
}
// Ensure the display name ends in .html so it is recognisable once saved.
function normaliseName(name) {
const n = String(name || '').trim();
if (!n) return '';
return /\.html?$/i.test(n) ? n : `${n}.html`;
}
const PAGE_STYLE = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 840px; margin: 40px auto; padding: 0 22px; color: #16242b; line-height: 1.6;
background: #fff; -webkit-text-size-adjust: 100%; }
h1.doc-title { font-size: 26px; margin: 0 0 6px; letter-spacing: -.01em; }
.doc-sub { color: #6b7b83; font-size: 13px; margin: 0 0 26px; }
.block { margin: 0 0 20px; }
.block p { white-space: pre-wrap; margin: 0; }
figure { margin: 0 0 20px; }
figure img { max-width: 100%; height: auto; border-radius: 10px; border: 1px solid #e6ebed; display: block; }
figcaption { color: #6b7b83; font-size: 13px; margin-top: 8px; }
.doc-foot { color: #9aa7ad; font-size: 12px; border-top: 1px solid #e6ebed; margin-top: 40px; padding-top: 14px; }
.tl { list-style: none; margin: 8px 0 0; padding: 0 0 0 26px; border-left: 3px solid #2aa596; }
.tl > li { position: relative; margin: 0 0 26px; }
.tl > li::before { content: ""; position: absolute; left: -34px; top: 3px; width: 13px; height: 13px;
background: #2aa596; border: 3px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px #2aa596; }
.ev-name { font-weight: 700; font-size: 16px; }
.ev-when { color: #2a7d73; font-size: 13px; font-weight: 600; margin-top: 2px; }
.ev-data { white-space: pre-wrap; margin-top: 6px; color: #33454d; }
.branches { list-style: none; margin: 14px 0 0; padding: 0 0 0 22px; border-left: 2px dashed #c7d2d6; }
.branches > li { position: relative; margin: 0 0 16px; }
.branches > li::before { content: ""; position: absolute; left: -29px; top: 4px; width: 10px; height: 10px;
background: #fff; border: 2px solid #7fb8b0; border-radius: 50%; }
.branches .ev-name { font-size: 14.5px; }
@media print { body { margin: 0; } }
`;
function pageShell(title, inner) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<style>${PAGE_STYLE}</style>
</head>
<body>
${inner}
<div class="doc-foot">Created with Infosec · Data Creation — ${esc(fmtDate(new Date().toISOString().slice(0, 10)))}</div>
</body>
</html>`;
}
function buildDocumentHtml(title, blocks, images) {
const head = title ? `<h1 class="doc-title">${esc(title)}</h1>` : '';
const body = blocks.map((b) => {
if (b.type === 'image') {
const img = images[b.img];
if (!img) return '';
const dataUri = `data:${img.mimetype};base64,${img.buffer.toString('base64')}`;
const cap = b.caption ? `<figcaption>${esc(b.caption)}</figcaption>` : '';
return `<figure>\n <img src="${dataUri}" alt="${esc(b.caption || '')}">\n ${cap}\n</figure>`;
}
// text
return `<div class="block"><p>${esc(b.text)}</p></div>`;
}).join('\n');
return pageShell(title || 'Document', `${head}\n${body}`);
}
function renderEvent(e) {
const when = [e.date ? fmtDate(e.date) : '', e.time ? String(e.time).trim() : '']
.filter(Boolean).join(' · ');
const whenLine = when ? `<div class="ev-when">${esc(when)}</div>` : '';
const dataLine = (e.data && String(e.data).trim())
? `<div class="ev-data">${esc(e.data)}</div>` : '';
const branches = (Array.isArray(e.branches) && e.branches.length)
? `<ul class="branches">${e.branches.map((b) => `<li>${renderEvent(b)}</li>`).join('')}</ul>`
: '';
return `<div class="ev-name">${esc(e.name)}</div>${whenLine}${dataLine}${branches}`;
}
function buildTimelineHtml(title, events) {
const head = title ? `<h1 class="doc-title">${esc(title)}</h1>` : '';
const items = events.map((ev) => `<li>${renderEvent(ev)}</li>`).join('\n');
return pageShell(title || 'Timeline', `${head}\n<ul class="tl">\n${items}\n</ul>`);
}
// Persist a generated HTML string as a file record + on-disk file.
function saveHtmlFile(req, res, html, displayName, folderId) {
const buf = Buffer.from(html, 'utf8');
const storedName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.html`;
fs.writeFileSync(path.join(UPLOAD_DIR, storedName), buf);
const info = db.prepare(
`INSERT INTO files (name, stored_name, size, mime, folder_id, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)`
).run(displayName, storedName, buf.length, 'text/html', folderId, req.session.user.id);
log(req, 'FILE_CREATE', 'Data Management',
`Created file "${displayName}" (${buf.length} bytes) in folder ${folderId || 'root'} via Data Creation`);
return res.json({ ok: true, file: { id: info.lastInsertRowid, name: displayName, folder_id: folderId } });
}
// ---- Document ---------------------------------------------------------
router.post('/document', upload.array('images'), (req, res) => {
let spec;
try { spec = JSON.parse(req.body.spec || '[]'); }
catch (_) { return res.status(400).json({ error: 'Invalid document content.' }); }
if (!Array.isArray(spec) || spec.length === 0) {
return res.status(400).json({ error: 'Add at least one text or image block.' });
}
const images = req.files || [];
for (const b of spec) {
if (b && b.type === 'image') {
if (typeof b.img !== 'number' || !images[b.img]) {
return res.status(400).json({ error: 'An image block has no image selected.' });
}
if (!/^image\//.test(images[b.img].mimetype || '')) {
return res.status(400).json({ error: 'Only image files can be added to a document.' });
}
} else if (!b || b.type !== 'text' || !String(b.text || '').trim()) {
return res.status(400).json({ error: 'Every text block needs some text.' });
}
}
const title = String(req.body.title || '').trim();
const html = buildDocumentHtml(title, spec, images);
if (String(req.body.preview) === 'true') return res.json({ ok: true, html });
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
if (!folderExists(folderId)) return res.status(400).json({ error: 'Choose an existing folder.' });
const displayName = normaliseName(req.body.name);
if (!displayName) return res.status(400).json({ error: 'A file name is required.' });
return saveHtmlFile(req, res, html, displayName, folderId);
});
// ---- Timeline ---------------------------------------------------------
function cleanEvent(e) {
return {
name: String((e && e.name) || '').trim(),
date: String((e && e.date) || '').trim(),
time: String((e && e.time) || '').trim(),
data: String((e && e.data) || '').trim(),
branches: Array.isArray(e && e.branches) ? e.branches.map(cleanEvent) : [],
};
}
router.post('/timeline', (req, res) => {
const raw = Array.isArray(req.body.events) ? req.body.events : [];
if (raw.length === 0) return res.status(400).json({ error: 'Add at least one event.' });
const events = raw.map(cleanEvent);
const missing = (list) => list.some((e) => !e.name || (e.branches.length && missing(e.branches)));
if (missing(events)) return res.status(400).json({ error: 'Every event and branch needs a Name.' });
const title = String(req.body.title || '').trim();
const html = buildTimelineHtml(title, events);
if (req.body.preview === true || String(req.body.preview) === 'true') {
return res.json({ ok: true, html });
}
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
if (!folderExists(folderId)) return res.status(400).json({ error: 'Choose an existing folder.' });
const displayName = normaliseName(req.body.name);
if (!displayName) return res.status(400).json({ error: 'A file name is required.' });
return saveHtmlFile(req, res, html, displayName, folderId);
});
module.exports = router;