template-studio/server/templatePack.js

277 lines
8.4 KiB
JavaScript

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,
pageMarginTop: 12,
pageMarginRight: 12,
pageMarginBottom: 12,
pageMarginLeft: 12,
pagePadding: 18,
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',
variant: 'hero',
maxHeight: 260,
fit: 'cover'
}
}
]
},
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' }
]
}