502 lines
16 KiB
JavaScript
502 lines
16 KiB
JavaScript
import crypto from 'node:crypto'
|
|
import express from 'express'
|
|
import { chromium } from 'playwright-core'
|
|
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'
|
|
|
|
const app = express()
|
|
const port = Number(process.env.PORT || 3000)
|
|
const tenantId = process.env.TENANT_ID || process.env.TEMPLATE_TENANT_ID || 'default'
|
|
|
|
app.set('trust proxy', 1)
|
|
app.disable('x-powered-by')
|
|
app.use(express.json({ limit: '2mb' }))
|
|
|
|
app.get('/api/health/ready', async (_req, res) => {
|
|
try {
|
|
await getPool().query('SELECT 1')
|
|
res.json({ ok: true })
|
|
} catch (error) {
|
|
res.status(503).json({ ok: false, error: error.message })
|
|
}
|
|
})
|
|
|
|
registerAuthRoutes(app)
|
|
|
|
app.get('/media/:token/:filename', async (req, res, next) => {
|
|
try {
|
|
const asset = await getTemplateAsset(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)
|
|
}
|
|
})
|
|
|
|
function userId(req) {
|
|
return req.user?.preferred_username || req.user?.sub || 'unknown'
|
|
}
|
|
|
|
function keyHash(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
function bearerToken(req) {
|
|
const header = String(req.headers.authorization || '')
|
|
return header.startsWith('Bearer ') ? header.slice(7).trim() : ''
|
|
}
|
|
|
|
function apiKeyFrom(req) {
|
|
const header = String(req.headers['x-api-key'] || '').trim()
|
|
if (header) return header
|
|
const authorization = String(req.headers.authorization || '')
|
|
return authorization.startsWith('ApiKey ') ? authorization.slice(7).trim() : ''
|
|
}
|
|
|
|
async function requireConsumerAuth(req, res, next) {
|
|
try {
|
|
const session = readSession(req)
|
|
if (session) {
|
|
req.consumer = { type: 'session', subject: session.sub || session.preferred_username }
|
|
return next()
|
|
}
|
|
|
|
const apiKey = apiKeyFrom(req)
|
|
if (apiKey) {
|
|
const { rows } = await getPool().query(
|
|
`SELECT id,name FROM api_keys
|
|
WHERE tenant_id=$1 AND key_hash=$2 AND revoked_at IS NULL
|
|
LIMIT 1`,
|
|
[tenantId, keyHash(apiKey)]
|
|
)
|
|
if (!rows[0]) return res.status(401).json({ error: 'invalid_api_key' })
|
|
await getPool().query('UPDATE api_keys SET last_used_at=now() WHERE id=$1', [rows[0].id])
|
|
req.consumer = { type: 'api_key', subject: rows[0].name }
|
|
return next()
|
|
}
|
|
|
|
const token = bearerToken(req)
|
|
if (token) {
|
|
try {
|
|
const payload = await verifyMachineToken(token)
|
|
req.consumer = {
|
|
type: 'keycloak',
|
|
subject: payload.azp || payload.sub
|
|
}
|
|
return next()
|
|
} catch {
|
|
return res.status(401).json({ error: 'invalid_bearer_token' })
|
|
}
|
|
}
|
|
|
|
return res.status(401).json({
|
|
error: 'consumer_auth_required',
|
|
accepted: ['X-API-Key', 'Authorization: Bearer <Keycloak access token>']
|
|
})
|
|
} catch (error) {
|
|
next(error)
|
|
}
|
|
}
|
|
|
|
function defaultDocument(type) {
|
|
return {
|
|
blocks: [
|
|
{
|
|
id: crypto.randomUUID(),
|
|
type: 'heading',
|
|
props: { text: type === 'email' ? 'Hello {{customer.name}}' : 'Report — {{report.date}}' }
|
|
},
|
|
{
|
|
id: crypto.randomUUID(),
|
|
type: 'text',
|
|
props: { text: type === 'email' ? 'Add your message here.' : 'Add report context here.' }
|
|
},
|
|
{
|
|
id: crypto.randomUUID(),
|
|
type: 'kpi',
|
|
props: { label: 'Total', value: '{{summary.total}}' }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
function slug(value) {
|
|
return String(value || '')
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 48) || 'template'
|
|
}
|
|
|
|
async function newTemplateKey(name) {
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const candidate = `${slug(name)}-${crypto.randomBytes(3).toString('hex')}`
|
|
const { rowCount } = await getPool().query(
|
|
'SELECT 1 FROM templates WHERE tenant_id=$1 AND template_key=$2',
|
|
[tenantId, candidate]
|
|
)
|
|
if (!rowCount) return candidate
|
|
}
|
|
return `template-${crypto.randomUUID()}`
|
|
}
|
|
|
|
async function publishedTemplateByKey(key) {
|
|
const { rows } = await getPool().query(
|
|
`SELECT
|
|
t.id,
|
|
t.template_key,
|
|
t.name,
|
|
t.type,
|
|
t.status,
|
|
t.published_version,
|
|
t.updated_at,
|
|
v.document,
|
|
v.created_at AS published_at
|
|
FROM templates t
|
|
JOIN template_versions v
|
|
ON v.template_id=t.id
|
|
AND v.version=t.published_version
|
|
WHERE t.tenant_id=$1
|
|
AND t.template_key=$2
|
|
AND t.status='published'
|
|
AND t.published_version IS NOT NULL
|
|
LIMIT 1`,
|
|
[tenantId, key]
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function sendRendered(res, type, document, data, format) {
|
|
if (format === 'email') {
|
|
if (type !== 'email') return res.status(400).json({ error: 'format_not_valid_for_template' })
|
|
return res.json({ html: renderEmailHtml(document, data) })
|
|
}
|
|
|
|
const html = renderReportHtml(document, data)
|
|
if (format === 'html') return res.json({ html })
|
|
if (format !== 'pdf') return res.status(400).json({ error: 'unsupported_format' })
|
|
if (type !== 'report') return res.status(400).json({ error: 'format_not_valid_for_template' })
|
|
|
|
const browser = await chromium.launch({
|
|
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
|
|
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
|
})
|
|
try {
|
|
const page = await browser.newPage()
|
|
await page.setContent(html, { waitUntil: 'networkidle' })
|
|
const pdf = await page.pdf({ format: 'A4', printBackground: true })
|
|
return res.type('application/pdf').send(pdf)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Published consumer API.
|
|
*
|
|
* Anonymous access is intentionally not supported. A caller must present
|
|
* either a Template Studio browser session, an API key, or a bearer token
|
|
* from one of TEMPLATE_API_ALLOWED_CLIENTS.
|
|
*/
|
|
app.use('/api/v1', requireConsumerAuth)
|
|
|
|
app.get('/api/v1/templates', async (_req, res) => {
|
|
const { rows } = await getPool().query(
|
|
`SELECT template_key AS key,name,type,published_version AS version,updated_at
|
|
FROM templates
|
|
WHERE tenant_id=$1 AND status='published' AND published_version IS NOT NULL
|
|
ORDER BY name`,
|
|
[tenantId]
|
|
)
|
|
res.setHeader('Cache-Control', 'private, max-age=30')
|
|
res.json({ templates: rows })
|
|
})
|
|
|
|
app.get('/api/v1/templates/:key', async (req, res) => {
|
|
const template = await publishedTemplateByKey(req.params.key)
|
|
if (!template) return res.status(404).json({ error: 'published_template_not_found' })
|
|
|
|
res.setHeader('Cache-Control', 'private, max-age=30')
|
|
res.json({
|
|
key: template.template_key,
|
|
name: template.name,
|
|
type: template.type,
|
|
version: template.published_version,
|
|
published_at: template.published_at,
|
|
document: template.document
|
|
})
|
|
})
|
|
|
|
app.post('/api/v1/templates/:key/render', async (req, res) => {
|
|
const template = await publishedTemplateByKey(req.params.key)
|
|
if (!template) return res.status(404).json({ error: 'published_template_not_found' })
|
|
|
|
const data = req.body?.data ?? {}
|
|
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
await sendRendered(res, template.type, template.document, data, format)
|
|
})
|
|
|
|
/**
|
|
* Everything below this point is the interactive editor API and requires
|
|
* an authenticated Template Studio browser session.
|
|
*/
|
|
app.use('/api', requireAuth)
|
|
|
|
app.get('/api/template-packs/spec', (_req, res) => {
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
res.json(templatePackSpec)
|
|
})
|
|
|
|
app.post(
|
|
'/api/template-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 importTemplatePack(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: 'pack_too_large' })
|
|
}
|
|
if (error instanceof Error) {
|
|
return res.status(400).json({ error: 'invalid_template_pack', detail: error.message })
|
|
}
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
app.get('/api/templates', async (_req, res) => {
|
|
const { rows } = await getPool().query(
|
|
`SELECT id,template_key,name,type,status,published_version,updated_at
|
|
FROM templates
|
|
WHERE tenant_id=$1
|
|
ORDER BY updated_at DESC`,
|
|
[tenantId]
|
|
)
|
|
res.json(rows)
|
|
})
|
|
|
|
app.post('/api/templates', async (req, res) => {
|
|
const name = String(req.body?.name || '').trim()
|
|
const type = req.body?.type === 'email' ? 'email' : 'report'
|
|
if (!name) return res.status(400).json({ error: 'name_required' })
|
|
|
|
const id = crypto.randomUUID()
|
|
const templateKey = await newTemplateKey(name)
|
|
const document = defaultDocument(type)
|
|
const sampleData = {
|
|
customer: { name: 'Example Customer' },
|
|
report: { date: new Date().toISOString().slice(0, 10) },
|
|
summary: { total: 125 },
|
|
items: [{ name: 'Example row', value: 125 }]
|
|
}
|
|
const { rows } = await getPool().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)
|
|
RETURNING *`,
|
|
[id, tenantId, templateKey, name, type, document, sampleData, userId(req)]
|
|
)
|
|
res.status(201).json(rows[0])
|
|
})
|
|
|
|
app.get('/api/templates/:id', async (req, res) => {
|
|
const { rows } = await getPool().query(
|
|
'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2',
|
|
[req.params.id, tenantId]
|
|
)
|
|
if (!rows[0]) return res.status(404).json({ error: 'not_found' })
|
|
res.json(rows[0])
|
|
})
|
|
|
|
app.put('/api/templates/:id/draft', async (req, res) => {
|
|
const document = req.body?.document
|
|
const sampleData = req.body?.sampleData ?? {}
|
|
if (!document || !Array.isArray(document.blocks)) {
|
|
return res.status(400).json({ error: 'invalid_document' })
|
|
}
|
|
const { rows } = await getPool().query(
|
|
`UPDATE templates
|
|
SET draft_document=$1,sample_data=$2,updated_by=$3,updated_at=now()
|
|
WHERE id=$4 AND tenant_id=$5
|
|
RETURNING *`,
|
|
[document, sampleData, userId(req), req.params.id, tenantId]
|
|
)
|
|
if (!rows[0]) return res.status(404).json({ error: 'not_found' })
|
|
res.json(rows[0])
|
|
})
|
|
|
|
app.post('/api/templates/:id/publish', async (req, res) => {
|
|
const db = await getPool().connect()
|
|
try {
|
|
await db.query('BEGIN')
|
|
const current = await db.query(
|
|
'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2 FOR UPDATE',
|
|
[req.params.id, tenantId]
|
|
)
|
|
const template = current.rows[0]
|
|
if (!template) {
|
|
await db.query('ROLLBACK')
|
|
return res.status(404).json({ error: 'not_found' })
|
|
}
|
|
|
|
const nextVersion = (template.published_version || 0) + 1
|
|
await db.query(
|
|
`INSERT INTO template_versions
|
|
(id,template_id,version,document,sample_data,created_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
[
|
|
crypto.randomUUID(),
|
|
template.id,
|
|
nextVersion,
|
|
template.draft_document,
|
|
template.sample_data,
|
|
userId(req)
|
|
]
|
|
)
|
|
const updated = await db.query(
|
|
`UPDATE templates
|
|
SET status='published',published_version=$1,updated_at=now(),updated_by=$2
|
|
WHERE id=$3
|
|
RETURNING *`,
|
|
[nextVersion, userId(req), template.id]
|
|
)
|
|
await db.query('COMMIT')
|
|
res.json(updated.rows[0])
|
|
} catch (error) {
|
|
await db.query('ROLLBACK')
|
|
throw error
|
|
} finally {
|
|
db.release()
|
|
}
|
|
})
|
|
|
|
app.post('/api/templates/:id/render', async (req, res) => {
|
|
const { rows } = await getPool().query(
|
|
'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2',
|
|
[req.params.id, tenantId]
|
|
)
|
|
const template = rows[0]
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const data = req.body?.data ?? template.sample_data ?? {}
|
|
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
await sendRendered(res, template.type, template.draft_document, data, format)
|
|
})
|
|
|
|
app.get('/api/api-keys', async (_req, res) => {
|
|
const { rows } = await getPool().query(
|
|
`SELECT id,name,key_prefix,created_at,last_used_at
|
|
FROM api_keys
|
|
WHERE tenant_id=$1 AND revoked_at IS NULL
|
|
ORDER BY created_at DESC`,
|
|
[tenantId]
|
|
)
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
res.json(rows)
|
|
})
|
|
|
|
app.post('/api/api-keys', async (req, res) => {
|
|
const name = String(req.body?.name || '').trim()
|
|
if (!name) return res.status(400).json({ error: 'name_required' })
|
|
|
|
const prefix = crypto.randomBytes(5).toString('hex')
|
|
const secret = crypto.randomBytes(24).toString('base64url')
|
|
const apiKey = `ts_live_${prefix}_${secret}`
|
|
const id = crypto.randomUUID()
|
|
|
|
const { rows } = await getPool().query(
|
|
`INSERT INTO api_keys
|
|
(id,tenant_id,name,key_prefix,key_hash,created_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6)
|
|
RETURNING id,name,key_prefix,created_at,last_used_at`,
|
|
[id, tenantId, name, `ts_live_${prefix}`, keyHash(apiKey), userId(req)]
|
|
)
|
|
|
|
res.status(201).json({
|
|
...rows[0],
|
|
api_key: apiKey
|
|
})
|
|
})
|
|
|
|
app.delete('/api/api-keys/:id', async (req, res) => {
|
|
const { rowCount } = await getPool().query(
|
|
`UPDATE api_keys
|
|
SET revoked_at=now()
|
|
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
|
|
[req.params.id, tenantId]
|
|
)
|
|
if (!rowCount) return res.status(404).json({ error: 'not_found' })
|
|
res.status(204).end()
|
|
})
|
|
|
|
app.get('/reset-pwa', (_req, res) => {
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
res.type('html').send(`<!doctype html>
|
|
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Resetting Template Studio</title></head>
|
|
<body style="font-family:system-ui,-apple-system,sans-serif;padding:32px;color:#172033">
|
|
<h1>Resetting Template Studio…</h1>
|
|
<p>This clears only Template Studio's local PWA caches and service worker.</p>
|
|
<script>
|
|
(async () => {
|
|
try {
|
|
if ('serviceWorker' in navigator) {
|
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
|
await Promise.all(registrations.map(registration => registration.unregister()))
|
|
}
|
|
if ('caches' in window) {
|
|
const names = await caches.keys()
|
|
await Promise.all(names.map(name => caches.delete(name)))
|
|
}
|
|
} finally {
|
|
location.replace('/?fresh=' + Date.now())
|
|
}
|
|
})()
|
|
</script>
|
|
</body></html>`)
|
|
})
|
|
|
|
app.use(express.static('dist', {
|
|
setHeaders(res, filePath) {
|
|
if (filePath.endsWith('/index.html') || filePath.endsWith('/sw.js') || filePath.endsWith('/manifest.webmanifest')) {
|
|
res.setHeader('Cache-Control', 'no-store, max-age=0')
|
|
return
|
|
}
|
|
if (filePath.includes('/assets/')) {
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
|
}
|
|
}
|
|
}))
|
|
|
|
app.use('/assets', (_req, res) => res.status(404).type('text').send('Asset not found'))
|
|
|
|
app.get(/^(?!\/api|\/auth|\/reset-pwa).*/, (_req, res) => {
|
|
res.setHeader('Cache-Control', 'no-store, max-age=0')
|
|
res.sendFile('index.html', { root: 'dist' })
|
|
})
|
|
app.use((error, _req, res, _next) => {
|
|
console.error(error)
|
|
res.status(500).json({ error: 'internal_error' })
|
|
})
|
|
|
|
await initDb()
|
|
app.listen(port, '0.0.0.0', () => console.log(`Template Studio listening on :${port}`))
|