diff --git a/server/db.js b/server/db.js index 4d49261..ff9b754 100644 --- a/server/db.js +++ b/server/db.js @@ -71,6 +71,35 @@ export async function initDb() { ) `) + await db.query(` + CREATE TABLE IF NOT EXISTS themes ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + name text NOT NULL, + applies_to text NOT NULL DEFAULT 'all' CHECK (applies_to IN ('all', 'report', 'email')), + theme jsonb NOT NULL, + created_by text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `) + + await db.query(` + CREATE TABLE IF NOT EXISTS theme_assets ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + theme_id text NOT NULL REFERENCES themes(id) ON DELETE CASCADE, + asset_key text NOT NULL, + filename text NOT NULL, + mime_type text NOT NULL, + byte_size integer NOT NULL, + public_token text NOT NULL UNIQUE, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(theme_id, asset_key) + ) + `) + await db.query(` CREATE TABLE IF NOT EXISTS api_keys ( id text PRIMARY KEY, @@ -101,4 +130,12 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS template_assets_template_idx ON template_assets (template_id) `) + await db.query(` + CREATE INDEX IF NOT EXISTS themes_tenant_created_idx + ON themes (tenant_id, created_at DESC) + `) + await db.query(` + CREATE INDEX IF NOT EXISTS theme_assets_theme_idx + ON theme_assets (theme_id) + `) } diff --git a/server/index.js b/server/index.js index 92664eb..456a527 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ 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' +import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js' const app = express() const port = Number(process.env.PORT || 3000) @@ -25,6 +26,20 @@ app.get('/api/health/ready', async (_req, res) => { registerAuthRoutes(app) +app.get('/theme-media/:token/:filename', async (req, res, next) => { + try { + const asset = await getThemeAsset(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) + } +}) + app.get('/media/:token/:filename', async (req, res, next) => { try { const asset = await getTemplateAsset(req.params.token) @@ -255,6 +270,60 @@ app.get('/api/template-packs/spec', (_req, res) => { res.json(templatePackSpec) }) +app.get('/api/theme-packs/spec', (_req, res) => { + res.setHeader('Cache-Control', 'no-store') + res.json(themePackSpec) +}) + +app.get('/api/themes', async (_req, res) => { + const { rows } = await getPool().query( + `SELECT id,name,applies_to,theme,created_at,updated_at + FROM themes + WHERE tenant_id=$1 + ORDER BY created_at DESC`, + [tenantId] + ) + res.setHeader('Cache-Control', 'no-store') + res.json(rows) +}) + +app.post('/api/theme-packs/import-json', async (req, res) => { + try { + const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '') + const imported = await importThemeJson(req.body, { + tenantId, + createdBy: userId(req), + baseUrl + }) + res.status(201).json(imported) + } catch (error) { + res.status(400).json({ error: 'invalid_theme_pack', detail: error instanceof Error ? error.message : String(error) }) + } +}) + +app.post( + '/api/theme-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 importThemePack(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: 'theme_pack_too_large' }) + if (error instanceof Error) return res.status(400).json({ error: 'invalid_theme_pack', detail: error.message }) + next(error) + } + } +) + app.post( '/api/template-packs/import', express.raw({ diff --git a/server/themePack.js b/server/themePack.js new file mode 100644 index 0000000..b61c467 --- /dev/null +++ b/server/themePack.js @@ -0,0 +1,215 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import AdmZip from 'adm-zip' +import { getPool } from './db.js' + +const MAX_FILES = 60 +const MAX_UNCOMPRESSED_BYTES = 30 * 1024 * 1024 +const MAX_ASSET_BYTES = 10 * 1024 * 1024 +const MAX_MANIFEST_BYTES = 128 * 1024 + +const MIME_BY_EXT = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif' +} + +function cleanArchivePath(value) { + const normalized = String(value || '').replaceAll('\\\\', '/') + if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized.includes('/..')) { + throw new Error('Theme pack contains an unsafe path') + } + return normalized +} + +function replaceAssetRefs(value, assetUrls) { + if (typeof value === 'string') { + return value.replace(/asset:\/\/([a-zA-Z0-9._-]+)/g, (full, key) => assetUrls[key] || full) + } + if (Array.isArray(value)) return value.map(item => replaceAssetRefs(item, assetUrls)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, replaceAssetRefs(child, assetUrls)]) + ) + } + return value +} + +function validateManifest(manifest) { + if (!manifest || manifest.format !== 'template-studio-theme' || manifest.version !== 1) { + throw new Error('theme-pack.json must use format "template-studio-theme" version 1') + } + if (typeof manifest.name !== 'string' || !manifest.name.trim()) { + throw new Error('Theme name is required') + } + if (!manifest.theme || typeof manifest.theme !== 'object' || Array.isArray(manifest.theme)) { + throw new Error('Theme object is required') + } + + const appliesTo = ['all', 'report', 'email'].includes(manifest.appliesTo) ? manifest.appliesTo : 'all' + const assets = Array.isArray(manifest.assets) ? manifest.assets : [] + const keys = new Set() + + for (const asset of assets) { + if (!asset || typeof asset.key !== 'string' || !/^[a-zA-Z0-9._-]{1,80}$/.test(asset.key)) { + throw new Error('Each theme asset requires a simple key') + } + if (keys.has(asset.key)) throw new Error(`Duplicate theme asset key: ${asset.key}`) + keys.add(asset.key) + cleanArchivePath(asset.file) + } + + return { + name: manifest.name.trim(), + appliesTo, + theme: manifest.theme, + assets + } +} + +async function saveTheme({ name, appliesTo, theme, assets = [], entryMap, tenantId, createdBy, baseUrl }) { + const db = await getPool().connect() + try { + await db.query('BEGIN') + + const themeId = crypto.randomUUID() + const assetUrls = {} + + await db.query( + `INSERT INTO themes (id,tenant_id,name,applies_to,theme,created_by) + VALUES ($1,$2,$3,$4,$5,$6)`, + [themeId, tenantId, name, appliesTo, {}, createdBy] + ) + + for (const asset of assets) { + const archivePath = cleanArchivePath(asset.file) + const entry = entryMap?.get(archivePath) + if (!entry) throw new Error(`Missing theme asset file: ${archivePath}`) + + const extension = path.extname(archivePath).toLowerCase() + const mimeType = MIME_BY_EXT[extension] + if (!mimeType) throw new Error(`Unsupported theme asset type: ${archivePath}`) + + const data = entry.getData() + if (data.length > MAX_ASSET_BYTES) throw new Error(`Theme asset exceeds 10 MB: ${archivePath}`) + + const id = crypto.randomUUID() + const publicToken = crypto.randomBytes(24).toString('base64url') + const filename = path.basename(archivePath).replace(/[^a-zA-Z0-9._-]/g, '-') || `asset${extension}` + + await db.query( + `INSERT INTO theme_assets + (id,tenant_id,theme_id,asset_key,filename,mime_type,byte_size,public_token,data) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [id, tenantId, themeId, asset.key, filename, mimeType, data.length, publicToken, data] + ) + + assetUrls[asset.key] = `${baseUrl}/theme-media/${publicToken}/${encodeURIComponent(filename)}` + } + + const resolvedTheme = replaceAssetRefs(theme, assetUrls) + const unresolved = JSON.stringify(resolvedTheme).match(/asset:\/\/[a-zA-Z0-9._-]+/g) + if (unresolved?.length) throw new Error(`Unknown theme asset reference: ${unresolved[0]}`) + + const { rows } = await db.query( + `UPDATE themes + SET theme=$1,updated_at=now() + WHERE id=$2 + RETURNING id,name,applies_to,theme,created_at,updated_at`, + [resolvedTheme, themeId] + ) + + await db.query('COMMIT') + return { theme: rows[0], asset_count: assets.length } + } catch (error) { + await db.query('ROLLBACK') + throw error + } finally { + db.release() + } +} + +export async function importThemePack(buffer, { tenantId, createdBy, baseUrl }) { + if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Theme ZIP body is empty') + + const zip = new AdmZip(buffer) + const entries = zip.getEntries().filter(entry => !entry.isDirectory) + if (!entries.length || entries.length > MAX_FILES) { + throw new Error(`Theme pack must contain between 1 and ${MAX_FILES} files`) + } + + let totalBytes = 0 + const entryMap = new Map() + for (const entry of entries) { + const archivePath = cleanArchivePath(entry.entryName) + totalBytes += Number(entry.header?.size || 0) + if (totalBytes > MAX_UNCOMPRESSED_BYTES) throw new Error('Theme pack expands beyond the 30 MB limit') + entryMap.set(archivePath, entry) + } + + const manifestEntry = entryMap.get('theme-pack.json') + if (!manifestEntry) throw new Error('Theme pack is missing theme-pack.json') + if (Number(manifestEntry.header?.size || 0) > MAX_MANIFEST_BYTES) { + throw new Error('theme-pack.json is too large') + } + + let manifest + try { + manifest = JSON.parse(manifestEntry.getData().toString('utf8')) + } catch { + throw new Error('theme-pack.json is not valid JSON') + } + + const validated = validateManifest(manifest) + return saveTheme({ ...validated, entryMap, tenantId, createdBy, baseUrl }) +} + +export async function importThemeJson(manifest, context) { + const validated = validateManifest({ ...manifest, assets: [] }) + return saveTheme({ ...validated, ...context }) +} + +export async function getThemeAsset(publicToken) { + const { rows } = await getPool().query( + `SELECT filename,mime_type,byte_size,data + FROM theme_assets + WHERE public_token=$1 + LIMIT 1`, + [publicToken] + ) + return rows[0] || null +} + +export const themePackSpec = { + format: 'template-studio-theme', + version: 1, + name: 'Clean Operations', + appliesTo: 'report', + theme: { + backgroundColor: '#eef3f7', + surfaceColor: '#ffffff', + textColor: '#172b3a', + mutedColor: '#607282', + accentColor: '#123b5d', + buttonTextColor: '#ffffff', + borderColor: '#d8e1e8', + borderRadius: 8, + fontFamily: 'Arial', + reportDensity: 'auto', + pageMarginTop: 9, + pageMarginRight: 9, + pageMarginBottom: 9, + pageMarginLeft: 9, + pagePadding: 14, + pageBackgroundMode: 'none', + pageBackgroundImage: 'asset://background', + decorativeBorder: false, + borderImage: 'asset://border' + }, + assets: [ + { key: 'background', file: 'assets/background.webp' }, + { key: 'border', file: 'assets/border.png' } + ] +}