From 876e7d57af63e4beb2168b35a269cce84f5e05a7 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 04:01:53 +0000 Subject: [PATCH] feat: add secure AI template pack importer foundation --- docs/template-pack.md | 98 +++++++++++++++ package.json | 3 +- server/db.js | 20 ++++ server/templatePack.js | 265 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 docs/template-pack.md create mode 100644 server/templatePack.js diff --git a/docs/template-pack.md b/docs/template-pack.md new file mode 100644 index 0000000..422ed60 --- /dev/null +++ b/docs/template-pack.md @@ -0,0 +1,98 @@ +# AI Template Pack ZIP format + +Template Studio accepts authenticated ZIP uploads containing a manifest and raster assets. + +## Layout + +```text +my-pack.zip +├── template-pack.json +└── assets/ + ├── hero.webp + ├── paper-bg.webp + └── page-border.png +``` + +## Manifest + +```json +{ + "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" + } + ] +} +``` + +Use `asset://KEY` anywhere in the document or sample data. Import replaces it with an opaque Template Studio media URL. + +### Styling + +The constrained theme supports colors, border radius, background imagery and report border imagery. Blocks may also include a `style` object with background/text/border colors, border width/radius, padding and alignment. Arbitrary CSS/HTML/JavaScript is intentionally not supported. + +### Limits + +- ZIP upload: 20 MB compressed +- 100 files maximum +- 50 MB maximum expanded size +- 10 MB per image +- PNG, JPEG, WebP and GIF only +- `template-pack.json` must be at ZIP root diff --git a/package.json b/package.json index 2889906..5c1260a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "playwright-core": "^1.55.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "zod": "^4.1.11" + "zod": "^4.1.11", + "adm-zip": "^0.5.16" }, "devDependencies": { "@types/react": "^19.1.16", diff --git a/server/db.js b/server/db.js index 049b537..4d49261 100644 --- a/server/db.js +++ b/server/db.js @@ -55,6 +55,22 @@ export async function initDb() { ) `) + await db.query(` + CREATE TABLE IF NOT EXISTS template_assets ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + template_id text NOT NULL REFERENCES templates(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(template_id, asset_key) + ) + `) + await db.query(` CREATE TABLE IF NOT EXISTS api_keys ( id text PRIMARY KEY, @@ -81,4 +97,8 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS api_keys_tenant_created_idx ON api_keys (tenant_id, created_at DESC) `) + await db.query(` + CREATE INDEX IF NOT EXISTS template_assets_template_idx + ON template_assets (template_id) + `) } diff --git a/server/templatePack.js b/server/templatePack.js new file mode 100644 index 0000000..b434d74 --- /dev/null +++ b/server/templatePack.js @@ -0,0 +1,265 @@ +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' } + ] +}