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', baseFontSize: 15, headingSize: 24, lineHeight: 1.45, blockGap: 10, cardPadding: 12, tableCellPadding: 8, 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' } ] }