refine: update server/index.js
This commit is contained in:
parent
1a73981d3c
commit
6a4a0bca09
327
server/index.js
327
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 <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}}' } }
|
||||
{
|
||||
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'))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user