refine: update server/index.js

This commit is contained in:
Admin 2026-09-23 02:59:40 +00:00
parent 1a73981d3c
commit 6a4a0bca09

View File

@ -2,7 +2,7 @@ import crypto from 'node:crypto'
import express from 'express' import express from 'express'
import { chromium } from 'playwright-core' import { chromium } from 'playwright-core'
import { initDb, getPool } from './db.js' import { initDb, getPool } from './db.js'
import { registerAuthRoutes, requireAuth } from './auth.js' import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js'
import { renderEmailHtml, renderReportHtml } from './templateEngine.js' import { renderEmailHtml, renderReportHtml } from './templateEngine.js'
const app = express() const app = express()
@ -10,6 +10,7 @@ const port = Number(process.env.PORT || 3000)
const tenantId = process.env.TENANT_ID || process.env.TEMPLATE_TENANT_ID || 'default' const tenantId = process.env.TENANT_ID || process.env.TEMPLATE_TENANT_ID || 'default'
app.set('trust proxy', 1) app.set('trust proxy', 1)
app.disable('x-powered-by')
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))
app.get('/api/health/ready', async (_req, res) => { app.get('/api/health/ready', async (_req, res) => {
@ -22,25 +23,224 @@ app.get('/api/health/ready', async (_req, res) => {
}) })
registerAuthRoutes(app) registerAuthRoutes(app)
app.use('/api', requireAuth)
function userId(req) { function userId(req) {
return req.user?.preferred_username || req.user?.sub || 'unknown' 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) { function defaultDocument(type) {
return { return {
blocks: [ 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(),
{ id: crypto.randomUUID(), type: 'kpi', props: { label: 'Total', value: '{{summary.total}}' } } 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) => { app.get('/api/templates', async (_req, res) => {
const { rows } = await getPool().query( const { rows } = await getPool().query(
'SELECT id,name,type,status,published_version,updated_at FROM templates WHERE tenant_id=$1 ORDER BY updated_at DESC', `SELECT id,template_key,name,type,status,published_version,updated_at
FROM templates
WHERE tenant_id=$1
ORDER BY updated_at DESC`,
[tenantId] [tenantId]
) )
res.json(rows) res.json(rows)
@ -50,7 +250,9 @@ app.post('/api/templates', async (req, res) => {
const name = String(req.body?.name || '').trim() const name = String(req.body?.name || '').trim()
const type = req.body?.type === 'email' ? 'email' : 'report' const type = req.body?.type === 'email' ? 'email' : 'report'
if (!name) return res.status(400).json({ error: 'name_required' }) if (!name) return res.status(400).json({ error: 'name_required' })
const id = crypto.randomUUID() const id = crypto.randomUUID()
const templateKey = await newTemplateKey(name)
const document = defaultDocument(type) const document = defaultDocument(type)
const sampleData = { const sampleData = {
customer: { name: 'Example Customer' }, customer: { name: 'Example Customer' },
@ -59,15 +261,20 @@ app.post('/api/templates', async (req, res) => {
items: [{ name: 'Example row', value: 125 }] items: [{ name: 'Example row', value: 125 }]
} }
const { rows } = await getPool().query( const { rows } = await getPool().query(
`INSERT INTO templates (id,tenant_id,name,type,draft_document,sample_data,created_by,updated_by) `INSERT INTO templates
VALUES ($1,$2,$3,$4,$5,$6,$7,$7) RETURNING *`, (id,tenant_id,template_key,name,type,draft_document,sample_data,created_by,updated_by)
[id, tenantId, name, type, document, sampleData, userId(req)] 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]) res.status(201).json(rows[0])
}) })
app.get('/api/templates/:id', async (req, res) => { 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]) 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' }) if (!rows[0]) return res.status(404).json({ error: 'not_found' })
res.json(rows[0]) res.json(rows[0])
}) })
@ -75,10 +282,14 @@ app.get('/api/templates/:id', async (req, res) => {
app.put('/api/templates/:id/draft', async (req, res) => { app.put('/api/templates/:id/draft', async (req, res) => {
const document = req.body?.document const document = req.body?.document
const sampleData = req.body?.sampleData ?? {} const sampleData = req.body?.sampleData ?? {}
if (!document || !Array.isArray(document.blocks)) return res.status(400).json({ error: 'invalid_document' }) if (!document || !Array.isArray(document.blocks)) {
return res.status(400).json({ error: 'invalid_document' })
}
const { rows } = await getPool().query( const { rows } = await getPool().query(
`UPDATE templates SET draft_document=$1,sample_data=$2,updated_by=$3,updated_at=now() `UPDATE templates
WHERE id=$4 AND tenant_id=$5 RETURNING *`, 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] [document, sampleData, userId(req), req.params.id, tenantId]
) )
if (!rows[0]) return res.status(404).json({ error: 'not_found' }) if (!rows[0]) return res.status(404).json({ error: 'not_found' })
@ -89,21 +300,35 @@ app.post('/api/templates/:id/publish', async (req, res) => {
const db = await getPool().connect() const db = await getPool().connect()
try { try {
await db.query('BEGIN') 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 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] const template = current.rows[0]
if (!template) { if (!template) {
await db.query('ROLLBACK') await db.query('ROLLBACK')
return res.status(404).json({ error: 'not_found' }) return res.status(404).json({ error: 'not_found' })
} }
const nextVersion = (template.published_version || 0) + 1 const nextVersion = (template.published_version || 0) + 1
await db.query( await db.query(
`INSERT INTO template_versions (id,template_id,version,document,sample_data,created_by) `INSERT INTO template_versions
(id,template_id,version,document,sample_data,created_by)
VALUES ($1,$2,$3,$4,$5,$6)`, VALUES ($1,$2,$3,$4,$5,$6)`,
[crypto.randomUUID(), template.id, nextVersion, template.draft_document, template.sample_data, userId(req)] [
crypto.randomUUID(),
template.id,
nextVersion,
template.draft_document,
template.sample_data,
userId(req)
]
) )
const updated = await db.query( const updated = await db.query(
`UPDATE templates SET status='published',published_version=$1,updated_at=now(),updated_by=$2 `UPDATE templates
WHERE id=$3 RETURNING *`, SET status='published',published_version=$1,updated_at=now(),updated_by=$2
WHERE id=$3
RETURNING *`,
[nextVersion, userId(req), template.id] [nextVersion, userId(req), template.id]
) )
await db.query('COMMIT') await db.query('COMMIT')
@ -117,31 +342,63 @@ app.post('/api/templates/:id/publish', async (req, res) => {
}) })
app.post('/api/templates/:id/render', async (req, res) => { 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 { rows } = await getPool().query(
'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2',
[req.params.id, tenantId]
)
const template = rows[0] const template = rows[0]
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const data = req.body?.data ?? template.sample_data ?? {} const data = req.body?.data ?? template.sample_data ?? {}
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html') const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
const document = template.draft_document res.setHeader('Cache-Control', 'no-store')
await sendRendered(res, template.type, template.draft_document, data, format)
})
if (format === 'email') return res.json({ html: renderEmailHtml(document, data) }) app.get('/api/api-keys', async (_req, res) => {
const html = renderReportHtml(document, data) const { rows } = await getPool().query(
if (format === 'html') return res.json({ html }) `SELECT id,name,key_prefix,created_at,last_used_at
if (format !== 'pdf') return res.status(400).json({ error: 'unsupported_format' }) 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)
})
const browser = await chromium.launch({ app.post('/api/api-keys', async (req, res) => {
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', const name = String(req.body?.name || '').trim()
args: ['--no-sandbox', '--disable-dev-shm-usage'] 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
}) })
try { })
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'networkidle' }) app.delete('/api/api-keys/:id', async (req, res) => {
const pdf = await page.pdf({ format: 'A4', printBackground: true }) const { rowCount } = await getPool().query(
res.type('application/pdf').send(pdf) `UPDATE api_keys
} finally { SET revoked_at=now()
await browser.close() 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.use(express.static('dist')) app.use(express.static('dist'))