feat: add secure AI template pack importer foundation
This commit is contained in:
parent
45acc3b550
commit
876e7d57af
98
docs/template-pack.md
Normal file
98
docs/template-pack.md
Normal file
@ -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
|
||||||
@ -20,7 +20,8 @@
|
|||||||
"playwright-core": "^1.55.0",
|
"playwright-core": "^1.55.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11",
|
||||||
|
"adm-zip": "^0.5.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
|
|||||||
20
server/db.js
20
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(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id text PRIMARY KEY,
|
id text PRIMARY KEY,
|
||||||
@ -81,4 +97,8 @@ export async function initDb() {
|
|||||||
CREATE INDEX IF NOT EXISTS api_keys_tenant_created_idx
|
CREATE INDEX IF NOT EXISTS api_keys_tenant_created_idx
|
||||||
ON api_keys (tenant_id, created_at DESC)
|
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)
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
265
server/templatePack.js
Normal file
265
server/templatePack.js
Normal file
@ -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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user