455 lines
14 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'
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)
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/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}`))