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 `
${html}
` +} + function renderTable(block, data) { const rows = getPath(data, block.props?.rowsPath) || [] const columns = Array.isArray(block.props?.columns) ? block.props.columns : [] @@ -33,60 +91,85 @@ function renderTable(block, data) { return `
${columns.map(c => ``).join('')}${rows.map(row => `${columns.map(c => ``).join('')}`).join('')}
${escapeHtml(c.label)}
${escapeHtml(getPath(row, c.key))}
` } -function blockHtml(block, data) { +function blockHtml(block, data, theme) { const p = block.props || {} + let html = '' switch (block.type) { case 'heading': - return `

${escapeHtml(interpolate(p.text, data))}

` + html = `

${escapeHtml(interpolate(p.text, data))}

` + break case 'text': - return `

${escapeHtml(interpolate(p.text, data))}

` + html = `

${escapeHtml(interpolate(p.text, data))}

` + break case 'kpi': - return `
${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}
` + html = `
${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}
` + break case 'button': - return `

${escapeHtml(interpolate(p.text, data))}

` + html = `

${escapeHtml(interpolate(p.text, data))}

` + break case 'image': - return `${escapeHtml(interpolate(p.alt, data))}` + html = `${escapeHtml(interpolate(p.alt, data))}` + break case 'divider': - return '
' + html = '
' + break case 'table': - return renderTable(block, data) + html = renderTable(block, data) + break default: return '' } + return wrapReportBlock(block, theme, html) } export function renderReportHtml(template, data = {}) { const blocks = template?.blocks || [] + const theme = themeOf(template) + const backgroundImage = theme.pageBackgroundImage === '#' ? 'none' : `url("${escapeHtml(theme.pageBackgroundImage)}")` + const borderImage = theme.borderImage === '#' ? 'none' : `url("${escapeHtml(theme.borderImage)}") 30 stretch` + return ` ${blocks.map(block => blockHtml(block, data)).join('\n')}` +@page{size:A4;margin:14mm}*{box-sizing:border-box}body{font:${15}px/1.5 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:no-repeat}.document{min-height:100%;background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 12}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 12px}.block:last-child{margin-bottom:0}h2{font-size:24px;margin:0 0 16px;color:inherit}p{margin:0 0 14px}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:14px;display:flex;justify-content:space-between;gap:16px;background:${theme.surfaceColor}}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:18px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{max-width:100%;height:auto;border-radius:${theme.borderRadius}px}hr{border:0;border-top:1px solid ${theme.borderColor};margin:18px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:9px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}} +
${blocks.map(block => blockHtml(block, data, theme)).join('\n')}
` } -function emailBlock(block, data) { +function emailBlock(block, data, theme) { const p = block.props || {} + const style = block.style || {} + const padding = `${safeNumber(style.padding, 0, 0, 48)}px` + const align = safeAlign(style.align) + const color = safeHex(style.textColor, theme.textColor) + const backgroundColor = safeHex(style.backgroundColor, theme.surfaceColor) + const borderColor = safeHex(style.borderColor, theme.borderColor) + const borderWidth = safeNumber(style.borderWidth, 0, 0, 8) + const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32) + const common = `padding="${padding}" align="${align}" color="${color}"` + switch (block.type) { case 'heading': - return `${escapeHtml(interpolate(p.text, data))}` + return `${escapeHtml(interpolate(p.text, data))}` case 'text': - return `${escapeHtml(interpolate(p.text, data))}` + return `${escapeHtml(interpolate(p.text, data))}` case 'kpi': - return `${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}` + return `${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}` case 'button': - return `${escapeHtml(interpolate(p.text, data))}` + return `${escapeHtml(interpolate(p.text, data))}` case 'image': - return `` + return `` case 'divider': - return '' + return `` default: return 'This block is available in report output only.' } } export function renderEmailHtml(template, data = {}) { - const body = (template?.blocks || []).map(block => emailBlock(block, data)).join('\n') - const mjml = `${body}` + const theme = themeOf(template) + const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n') + const backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"` + const mjml = `${body}` const result = mjml2html(mjml, { validationLevel: 'soft' }) if (result.errors?.length) console.warn('MJML warnings', result.errors) return result.html diff --git a/src/api.ts b/src/api.ts index e82b8a1..9658d4f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,8 @@ export async function api(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers) - if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + if (init.body && !(init.body instanceof FormData) && !(init.body instanceof Blob) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } const response = await fetch(path, { ...init, headers }) if (response.status === 401) { @@ -16,7 +18,9 @@ export async function api(path: string, init: RequestInit = {}): Promise { export async function apiBlob(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers) - if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + if (init.body && !(init.body instanceof FormData) && !(init.body instanceof Blob) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } const response = await fetch(path, { ...init, headers }) if (response.status === 401) { @@ -26,3 +30,20 @@ export async function apiBlob(path: string, init: RequestInit = {}): Promise(path: string, file: File): Promise { + const response = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/zip' }, + body: file + }) + if (response.status === 401) { + window.location.assign('/auth/login') + throw new Error('Authentication required') + } + if (!response.ok) { + const body = await response.text() + throw new Error(`${response.status}: ${body}`) + } + return response.json() as Promise +} diff --git a/src/types.ts b/src/types.ts index aa50336..0ab38b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,9 +5,11 @@ export type Block = { id: string type: BlockType props: Record + style?: Record } export type TemplateDocument = { + theme?: Record blocks: Block[] }