'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 ` ${esc(title)} ${inner}
Created with Infosec · Data Creation — ${esc(fmtDate(new Date().toISOString().slice(0, 10)))}
`; } function buildDocumentHtml(title, blocks, images) { const head = title ? `

${esc(title)}

` : ''; 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 ? `
${esc(b.caption)}
` : ''; return `
\n ${esc(b.caption || '')}\n ${cap}\n
`; } // text return `

${esc(b.text)}

`; }).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 ? `
${esc(when)}
` : ''; const dataLine = (e.data && String(e.data).trim()) ? `
${esc(e.data)}
` : ''; const branches = (Array.isArray(e.branches) && e.branches.length) ? `` : ''; return `
${esc(e.name)}
${whenLine}${dataLine}${branches}`; } function buildTimelineHtml(title, events) { const head = title ? `

${esc(title)}

` : ''; const items = events.map((ev) => `
  • ${renderEvent(ev)}
  • `).join('\n'); return pageShell(title || 'Timeline', `${head}\n`); } // 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;