226 lines
9.8 KiB
JavaScript
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) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|