diff --git a/server/index.js b/server/index.js index c30023a..92664eb 100644 --- a/server/index.js +++ b/server/index.js @@ -4,6 +4,7 @@ import { chromium } from 'playwright-core' import { initDb, getPool } from './db.js' import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js' import { renderEmailHtml, renderReportHtml } from './templateEngine.js' +import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js' const app = express() const port = Number(process.env.PORT || 3000) @@ -24,6 +25,20 @@ app.get('/api/health/ready', async (_req, res) => { registerAuthRoutes(app) +app.get('/media/:token/:filename', async (req, res, next) => { + try { + const asset = await getTemplateAsset(req.params.token) + if (!asset) return res.status(404).end() + res.setHeader('Content-Type', asset.mime_type) + res.setHeader('Content-Length', String(asset.byte_size)) + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.send(asset.data) + } catch (error) { + next(error) + } +}) + function userId(req) { return req.user?.preferred_username || req.user?.sub || 'unknown' } @@ -235,6 +250,38 @@ app.post('/api/v1/templates/:key/render', async (req, res) => { */ app.use('/api', requireAuth) +app.get('/api/template-packs/spec', (_req, res) => { + res.setHeader('Cache-Control', 'no-store') + res.json(templatePackSpec) +}) + +app.post( + '/api/template-packs/import', + express.raw({ + type: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'], + limit: '20mb' + }), + async (req, res, next) => { + try { + const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '') + const imported = await importTemplatePack(req.body, { + tenantId, + createdBy: userId(req), + baseUrl + }) + res.status(201).json(imported) + } catch (error) { + if (error?.type === 'entity.too.large') { + return res.status(413).json({ error: 'pack_too_large' }) + } + if (error instanceof Error) { + return res.status(400).json({ error: 'invalid_template_pack', detail: error.message }) + } + next(error) + } + } +) + app.get('/api/templates', async (_req, res) => { const { rows } = await getPool().query( `SELECT id,template_key,name,type,status,published_version,updated_at diff --git a/server/templateEngine.js b/server/templateEngine.js index 7f7d7da..a0b1fb8 100644 --- a/server/templateEngine.js +++ b/server/templateEngine.js @@ -26,6 +26,64 @@ function safeUrl(input, data) { return /^(https?:|mailto:)/i.test(url) ? url : '#' } +function safeHex(value, fallback) { + const color = String(value || '').trim() + return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : fallback +} + +function safeNumber(value, fallback, min, max) { + const number = Number(value) + if (!Number.isFinite(number)) return fallback + return Math.min(max, Math.max(min, number)) +} + +function safeAlign(value) { + return ['left', 'center', 'right'].includes(value) ? value : 'left' +} + +function safeFont(value) { + const font = String(value || '') + const allowed = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'system-ui'] + return allowed.includes(font) ? font : 'Arial' +} + +function themeOf(template) { + const theme = template?.theme || {} + return { + backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'), + surfaceColor: safeHex(theme.surfaceColor, '#ffffff'), + textColor: safeHex(theme.textColor, '#172033'), + mutedColor: safeHex(theme.mutedColor, '#64748b'), + accentColor: safeHex(theme.accentColor, '#111827'), + buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'), + borderColor: safeHex(theme.borderColor, '#dbe2ea'), + borderRadius: safeNumber(theme.borderRadius, 12, 0, 32), + pagePadding: safeNumber(theme.pagePadding, 18, 0, 40), + fontFamily: safeFont(theme.fontFamily), + pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}), + borderImage: safeUrl(theme.borderImage, {}) + } +} + +function reportBlockStyle(block, theme) { + const style = block?.style || {} + const rules = [ + `background:${safeHex(style.backgroundColor, 'transparent')}`, + `color:${safeHex(style.textColor, theme.textColor)}`, + `border-color:${safeHex(style.borderColor, theme.borderColor)}`, + `border-width:${safeNumber(style.borderWidth, 0, 0, 8)}px`, + 'border-style:solid', + `border-radius:${safeNumber(style.borderRadius, theme.borderRadius, 0, 32)}px`, + `padding:${safeNumber(style.padding, 0, 0, 48)}px`, + `text-align:${safeAlign(style.align)}` + ] + return rules.join(';') +} + +function wrapReportBlock(block, theme, html) { + return `
| ${escapeHtml(c.label)} | `).join('')}
|---|
| ${escapeHtml(getPath(row, c.key))} | `).join('')}
${escapeHtml(interpolate(p.text, data))}
` + html = `${escapeHtml(interpolate(p.text, data))}
` + break case 'kpi': - return `${escapeHtml(interpolate(p.text, data))}
` + html = `${escapeHtml(interpolate(p.text, data))}
` + break case 'image': - return `