feat: add reusable theme library backend
This commit is contained in:
parent
444990a3af
commit
9e5a06dd53
37
server/db.js
37
server/db.js
@ -71,6 +71,35 @@ export async function initDb() {
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS themes (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
applies_to text NOT NULL DEFAULT 'all' CHECK (applies_to IN ('all', 'report', 'email')),
|
||||
theme jsonb NOT NULL,
|
||||
created_by text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS theme_assets (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id text NOT NULL,
|
||||
theme_id text NOT NULL REFERENCES themes(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(theme_id, asset_key)
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id text PRIMARY KEY,
|
||||
@ -101,4 +130,12 @@ export async function initDb() {
|
||||
CREATE INDEX IF NOT EXISTS template_assets_template_idx
|
||||
ON template_assets (template_id)
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE INDEX IF NOT EXISTS themes_tenant_created_idx
|
||||
ON themes (tenant_id, created_at DESC)
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE INDEX IF NOT EXISTS theme_assets_theme_idx
|
||||
ON theme_assets (theme_id)
|
||||
`)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { initDb, getPool } from './db.js'
|
||||
import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js'
|
||||
import { renderEmailHtml, renderReportHtml } from './templateEngine.js'
|
||||
import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
|
||||
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT || 3000)
|
||||
@ -25,6 +26,20 @@ app.get('/api/health/ready', async (_req, res) => {
|
||||
|
||||
registerAuthRoutes(app)
|
||||
|
||||
app.get('/theme-media/:token/:filename', async (req, res, next) => {
|
||||
try {
|
||||
const asset = await getThemeAsset(req.params.token)
|
||||
if (!asset) return res.status(404).end()
|
||||
res.setHeader('Content-Type', asset.mime_type)
|
||||
res.setHeader('Content-Length', String(asset.byte_size))
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.send(asset.data)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/media/:token/:filename', async (req, res, next) => {
|
||||
try {
|
||||
const asset = await getTemplateAsset(req.params.token)
|
||||
@ -255,6 +270,60 @@ app.get('/api/template-packs/spec', (_req, res) => {
|
||||
res.json(templatePackSpec)
|
||||
})
|
||||
|
||||
app.get('/api/theme-packs/spec', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.json(themePackSpec)
|
||||
})
|
||||
|
||||
app.get('/api/themes', async (_req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id,name,applies_to,theme,created_at,updated_at
|
||||
FROM themes
|
||||
WHERE tenant_id=$1
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId]
|
||||
)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.json(rows)
|
||||
})
|
||||
|
||||
app.post('/api/theme-packs/import-json', async (req, res) => {
|
||||
try {
|
||||
const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '')
|
||||
const imported = await importThemeJson(req.body, {
|
||||
tenantId,
|
||||
createdBy: userId(req),
|
||||
baseUrl
|
||||
})
|
||||
res.status(201).json(imported)
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: 'invalid_theme_pack', detail: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/api/theme-packs/import',
|
||||
express.raw({
|
||||
type: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'],
|
||||
limit: '20mb'
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '')
|
||||
const imported = await importThemePack(req.body, {
|
||||
tenantId,
|
||||
createdBy: userId(req),
|
||||
baseUrl
|
||||
})
|
||||
res.status(201).json(imported)
|
||||
} catch (error) {
|
||||
if (error?.type === 'entity.too.large') return res.status(413).json({ error: 'theme_pack_too_large' })
|
||||
if (error instanceof Error) return res.status(400).json({ error: 'invalid_theme_pack', detail: error.message })
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/template-packs/import',
|
||||
express.raw({
|
||||
|
||||
215
server/themePack.js
Normal file
215
server/themePack.js
Normal file
@ -0,0 +1,215 @@
|
||||
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',
|
||||
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' }
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user