import crypto from 'node:crypto' import path from 'node:path' import AdmZip from 'adm-zip' import { getPool } from './db.js' const MAX_FILES = 100 const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024 const MAX_ASSET_BYTES = 10 * 1024 * 1024 const MAX_MANIFEST_BYTES = 256 * 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('Pack contains an unsafe path') } return normalized } function slug(value) { return String(value || '') .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || 'template' } async function uniqueTemplateKey(db, tenantId, name) { for (let attempt = 0; attempt < 8; attempt += 1) { const candidate = `${slug(name)}-${crypto.randomBytes(3).toString('hex')}` const { rowCount } = await db.query( 'SELECT 1 FROM templates WHERE tenant_id=$1 AND template_key=$2', [tenantId, candidate] ) if (!rowCount) return candidate } return `template-${crypto.randomUUID()}` } 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-pack' || manifest.version !== 1) { throw new Error('template-pack.json must use format "template-studio-pack" version 1') } const template = manifest.template if (!template || typeof template.name !== 'string' || !template.name.trim()) { throw new Error('Pack template.name is required') } if (!['email', 'report'].includes(template.type)) { throw new Error('Pack template.type must be "email" or "report"') } if (!template.document || !Array.isArray(template.document.blocks)) { throw new Error('Pack template.document.blocks must be an array') } 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 asset requires a simple key') } if (keys.has(asset.key)) throw new Error(`Duplicate asset key: ${asset.key}`) keys.add(asset.key) cleanArchivePath(asset.file) } return { template, assets } } export async function importTemplatePack(buffer, { tenantId, createdBy, baseUrl }) { if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('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(`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) const size = Number(entry.header?.size || 0) totalBytes += size if (totalBytes > MAX_UNCOMPRESSED_BYTES) throw new Error('Pack expands beyond the 50 MB limit') entryMap.set(archivePath, entry) } const manifestEntry = entryMap.get('template-pack.json') if (!manifestEntry) throw new Error('Pack is missing template-pack.json') if (Number(manifestEntry.header?.size || 0) > MAX_MANIFEST_BYTES) { throw new Error('template-pack.json is too large') } let manifest try { manifest = JSON.parse(manifestEntry.getData().toString('utf8')) } catch { throw new Error('template-pack.json is not valid JSON') } const { template, assets } = validateManifest(manifest) const db = await getPool().connect() try { await db.query('BEGIN') const templateId = crypto.randomUUID() const templateKey = await uniqueTemplateKey(db, tenantId, template.name) const placeholder = { blocks: [] } await db.query( `INSERT INTO templates (id,tenant_id,template_key,name,type,draft_document,sample_data,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)`, [ templateId, tenantId, templateKey, template.name.trim(), template.type, placeholder, template.sampleData || {}, createdBy ] ) const assetUrls = {} for (const asset of assets) { const archivePath = cleanArchivePath(asset.file) const entry = entryMap.get(archivePath) if (!entry) throw new Error(`Missing asset file: ${archivePath}`) const extension = path.extname(archivePath).toLowerCase() const mimeType = MIME_BY_EXT[extension] if (!mimeType) { throw new Error(`Unsupported asset type for ${archivePath}; use PNG, JPEG, WebP, or GIF`) } const data = entry.getData() if (data.length > MAX_ASSET_BYTES) throw new Error(`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 template_assets (id,tenant_id,template_id,asset_key,filename,mime_type,byte_size,public_token,data) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [id, tenantId, templateId, asset.key, filename, mimeType, data.length, publicToken, data] ) assetUrls[asset.key] = `${baseUrl}/media/${publicToken}/${encodeURIComponent(filename)}` } const document = replaceAssetRefs(template.document, assetUrls) const sampleData = replaceAssetRefs(template.sampleData || {}, assetUrls) const unresolved = JSON.stringify(document).match(/asset:\/\/[a-zA-Z0-9._-]+/g) if (unresolved?.length) { throw new Error(`Unknown asset reference: ${unresolved[0]}`) } const { rows } = await db.query( `UPDATE templates SET draft_document=$1,sample_data=$2,updated_at=now() WHERE id=$3 RETURNING *`, [document, sampleData, templateId] ) await db.query('COMMIT') return { template: rows[0], asset_count: assets.length } } catch (error) { await db.query('ROLLBACK') throw error } finally { db.release() } } export async function getTemplateAsset(publicToken) { const { rows } = await getPool().query( `SELECT filename,mime_type,byte_size,data FROM template_assets WHERE public_token=$1 LIMIT 1`, [publicToken] ) return rows[0] || null } export const templatePackSpec = { format: 'template-studio-pack', version: 1, template: { name: 'AI generated customer report', type: 'report', document: { theme: { backgroundColor: '#f6f1e8', surfaceColor: '#ffffff', textColor: '#172033', mutedColor: '#64748b', accentColor: '#8b5e34', borderColor: '#d8c7b3', borderRadius: 14, pageBackgroundImage: 'asset://paper-bg', borderImage: 'asset://page-border' }, blocks: [ { id: 'hero-heading', type: 'heading', props: { text: 'Report for {{customer.name}}' }, style: { backgroundColor: '#fffaf2', borderColor: '#d8c7b3', borderWidth: 1, borderRadius: 12, padding: 18 } }, { id: 'hero-image', type: 'image', props: { url: 'asset://hero', alt: 'Report hero' } } ] }, sampleData: { customer: { name: 'Example Customer' } } }, assets: [ { key: 'hero', file: 'assets/hero.webp' }, { key: 'paper-bg', file: 'assets/paper-bg.webp' }, { key: 'page-border', file: 'assets/page-border.png' } ] }