From 6a4a0bca099004a05590eba7663740f381b8fb91 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 02:59:40 +0000 Subject: [PATCH] refine: update server/index.js --- server/index.js | 327 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 292 insertions(+), 35 deletions(-) diff --git a/server/index.js b/server/index.js index 2af2bdb..b3bf223 100644 --- a/server/index.js +++ b/server/index.js @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import express from 'express' import { chromium } from 'playwright-core' 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' 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' app.set('trust proxy', 1) +app.disable('x-powered-by') app.use(express.json({ limit: '2mb' })) app.get('/api/health/ready', async (_req, res) => { @@ -22,25 +23,224 @@ app.get('/api/health/ready', async (_req, res) => { }) registerAuthRoutes(app) -app.use('/api', requireAuth) 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 '] + }) + } 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}}' } } + { + 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,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] ) res.json(rows) @@ -50,7 +250,9 @@ 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' }, @@ -59,15 +261,20 @@ app.post('/api/templates', async (req, res) => { items: [{ name: 'Example row', value: 125 }] } const { rows } = await getPool().query( - `INSERT INTO templates (id,tenant_id,name,type,draft_document,sample_data,created_by,updated_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$7) RETURNING *`, - [id, tenantId, name, type, document, sampleData, userId(req)] + `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]) + 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]) }) @@ -75,10 +282,14 @@ app.get('/api/templates/:id', async (req, res) => { 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' }) + 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 *`, + `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' }) @@ -89,21 +300,35 @@ 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 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) + `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)] + [ + 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 *`, + `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') @@ -117,31 +342,63 @@ app.post('/api/templates/:id/publish', 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] 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') - 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) }) - const html = renderReportHtml(document, data) - if (format === 'html') return res.json({ html }) - if (format !== 'pdf') return res.status(400).json({ error: 'unsupported_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) +}) - const browser = await chromium.launch({ - executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', - args: ['--no-sandbox', '--disable-dev-shm-usage'] +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 }) - try { - const page = await browser.newPage() - await page.setContent(html, { waitUntil: 'networkidle' }) - const pdf = await page.pdf({ format: 'A4', printBackground: true }) - res.type('application/pdf').send(pdf) - } finally { - await browser.close() - } +}) + +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.use(express.static('dist'))