803 lines
25 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, verifyMcpToken } from './auth.js'
import { renderEmailMessage, renderReportHtml } from './templateEngine.js'
import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
import { createTemplateMcpNodeHandler } from './mcp.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('/theme-media/:token/:filename', async (req, res, next) => {
try {
const asset = await getThemeAsset(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)
}
})
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, metadata = {}) {
if (format === 'email') {
if (type !== 'email') return res.status(400).json({ error: 'format_not_valid_for_template' })
return res.json(renderEmailMessage(document, data, metadata))
}
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()
}
}
function mcpBearerToken(req) {
const header = String(req.headers.authorization || '')
return header.startsWith('Bearer ') ? header.slice(7).trim() : ''
}
function validateMcpOrigin(req, res, next) {
const origin = String(req.headers.origin || '').trim()
const appBaseUrl = String(process.env.APP_BASE_URL || '').replace(/\/$/, '')
if (!origin || !appBaseUrl) return next()
try {
if (new URL(origin).origin !== new URL(appBaseUrl).origin) {
return res.status(403).json({ error: 'mcp_origin_not_allowed' })
}
} catch {
return res.status(403).json({ error: 'mcp_origin_not_allowed' })
}
next()
}
async function requireMcpAuth(req, res, next) {
const token = mcpBearerToken(req)
if (!token) {
res.setHeader('WWW-Authenticate', 'Bearer')
return res.status(401).json({ error: 'mcp_bearer_token_required' })
}
try {
const payload = await verifyMcpToken(token)
const clientId = String(payload.azp || '')
req.auth = {
token,
clientId,
scopes: String(payload.scope || '').split(/\s+/).filter(Boolean),
expiresAt: Number(payload.exp || 0) || undefined,
extra: {
subject: payload.sub,
preferred_username: payload.preferred_username
}
}
next()
} catch {
res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"')
return res.status(401).json({ error: 'invalid_mcp_bearer_token' })
}
}
const templateMcpNodeHandler = createTemplateMcpNodeHandler({ tenantId })
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
const issuer = String(process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, '')
const baseUrl = String(process.env.APP_BASE_URL || '').replace(/\/$/, '')
if (!issuer || !baseUrl) return res.status(404).end()
res.setHeader('Cache-Control', 'public, max-age=300')
res.json({
resource: `${baseUrl}/mcp`,
authorization_servers: [issuer],
bearer_methods_supported: ['header']
})
})
app.all('/mcp', validateMcpOrigin, requireMcpAuth, (req, res, next) => {
Promise.resolve(templateMcpNodeHandler(req, res, req.body)).catch(next)
})
/**
* 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, {
key: template.template_key,
name: template.name,
version: template.published_version,
published_at: template.published_at
})
})
/**
* 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.get('/api/theme-packs/spec', (_req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.json(themePackSpec)
})
app.get('/api/themes', async (_req, res) => {
const { rows } = await getPool().query(
`SELECT id,name,applies_to,theme,created_at,updated_at
FROM themes
WHERE tenant_id=$1
ORDER BY created_at DESC`,
[tenantId]
)
res.setHeader('Cache-Control', 'no-store')
res.json(rows)
})
app.post('/api/theme-packs/import-json', async (req, res) => {
try {
const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '')
const imported = await importThemeJson(req.body, {
tenantId,
createdBy: userId(req),
baseUrl
})
res.status(201).json(imported)
} catch (error) {
res.status(400).json({ error: 'invalid_theme_pack', detail: error instanceof Error ? error.message : String(error) })
}
})
app.post(
'/api/theme-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 importThemePack(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: 'theme_pack_too_large' })
if (error instanceof Error) return res.status(400).json({ error: 'invalid_theme_pack', detail: error.message })
next(error)
}
}
)
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/folders', async (_req, res) => {
const { rows } = await getPool().query(
`SELECT
f.id,
f.name,
f.parent_id,
f.created_at,
f.updated_at,
COUNT(t.id)::int AS template_count
FROM folders f
LEFT JOIN templates t
ON t.tenant_id=f.tenant_id
AND t.folder_id=f.id
WHERE f.tenant_id=$1
GROUP BY f.id
ORDER BY lower(f.name), f.created_at`,
[tenantId]
)
res.setHeader('Cache-Control', 'no-store')
res.json(rows)
})
app.post('/api/folders', async (req, res) => {
const name = String(req.body?.name || '').trim().slice(0, 120)
const parentId = req.body?.parentId ? String(req.body.parentId) : null
if (!name) return res.status(400).json({ error: 'name_required' })
if (parentId) {
const parent = await getPool().query(
'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2',
[parentId, tenantId]
)
if (!parent.rowCount) return res.status(400).json({ error: 'invalid_parent_folder' })
}
try {
const { rows } = await getPool().query(
`INSERT INTO folders (id,tenant_id,name,parent_id,created_by)
VALUES ($1,$2,$3,$4,$5)
RETURNING id,name,parent_id,created_at,updated_at,0::int AS template_count`,
[crypto.randomUUID(), tenantId, name, parentId, userId(req)]
)
res.status(201).json(rows[0])
} catch (error) {
if (error?.code === '23505') return res.status(409).json({ error: 'folder_name_exists' })
throw error
}
})
app.patch('/api/folders/:id', async (req, res) => {
const current = await getPool().query(
'SELECT * FROM folders WHERE id=$1 AND tenant_id=$2',
[req.params.id, tenantId]
)
if (!current.rows[0]) return res.status(404).json({ error: 'not_found' })
const name = req.body?.name === undefined
? current.rows[0].name
: String(req.body.name || '').trim().slice(0, 120)
const parentId = req.body?.parentId === undefined
? current.rows[0].parent_id
: (req.body.parentId ? String(req.body.parentId) : null)
if (!name) return res.status(400).json({ error: 'name_required' })
if (parentId === req.params.id) return res.status(400).json({ error: 'folder_cycle' })
if (parentId) {
const ancestry = await getPool().query(
`WITH RECURSIVE descendants AS (
SELECT id FROM folders WHERE id=$1 AND tenant_id=$2
UNION ALL
SELECT f.id
FROM folders f
JOIN descendants d ON f.parent_id=d.id
WHERE f.tenant_id=$2
)
SELECT 1 FROM descendants WHERE id=$3 LIMIT 1`,
[req.params.id, tenantId, parentId]
)
if (ancestry.rowCount) return res.status(400).json({ error: 'folder_cycle' })
const parent = await getPool().query(
'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2',
[parentId, tenantId]
)
if (!parent.rowCount) return res.status(400).json({ error: 'invalid_parent_folder' })
}
try {
const { rows } = await getPool().query(
`UPDATE folders
SET name=$1,parent_id=$2,updated_at=now()
WHERE id=$3 AND tenant_id=$4
RETURNING id,name,parent_id,created_at,updated_at`,
[name, parentId, req.params.id, tenantId]
)
res.json(rows[0])
} catch (error) {
if (error?.code === '23505') return res.status(409).json({ error: 'folder_name_exists' })
throw error
}
})
app.delete('/api/folders/:id', async (req, res) => {
const folder = await getPool().query(
'SELECT parent_id FROM folders WHERE id=$1 AND tenant_id=$2',
[req.params.id, tenantId]
)
if (!folder.rows[0]) return res.status(404).json({ error: 'not_found' })
const contents = await getPool().query(
`SELECT
EXISTS(SELECT 1 FROM folders WHERE tenant_id=$1 AND parent_id=$2) AS has_folders,
EXISTS(SELECT 1 FROM templates WHERE tenant_id=$1 AND folder_id=$2) AS has_templates`,
[tenantId, req.params.id]
)
if (contents.rows[0]?.has_folders || contents.rows[0]?.has_templates) {
return res.status(409).json({ error: 'folder_not_empty' })
}
await getPool().query('DELETE FROM folders WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId])
res.status(204).end()
})
app.patch('/api/templates/:id/folder', async (req, res) => {
const folderId = req.body?.folderId ? String(req.body.folderId) : null
if (folderId) {
const folder = await getPool().query(
'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2',
[folderId, tenantId]
)
if (!folder.rowCount) return res.status(400).json({ error: 'invalid_folder' })
}
const { rows } = await getPool().query(
`UPDATE templates
SET folder_id=$1,updated_by=$2,updated_at=now()
WHERE id=$3 AND tenant_id=$4
RETURNING id,template_key,name,type,status,published_version,folder_id,updated_at`,
[folderId, userId(req), req.params.id, tenantId]
)
if (!rows[0]) return res.status(404).json({ error: 'not_found' })
res.json(rows[0])
})
app.get('/api/templates', async (_req, res) => {
const { rows } = await getPool().query(
`SELECT id,template_key,name,type,status,published_version,folder_id,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'
const folderId = req.body?.folderId ? String(req.body.folderId) : null
if (!name) return res.status(400).json({ error: 'name_required' })
if (folderId) {
const folder = await getPool().query(
'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2',
[folderId, tenantId]
)
if (!folder.rowCount) return res.status(400).json({ error: 'invalid_folder' })
}
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,folder_id,draft_document,sample_data,created_by,updated_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9)
RETURNING *`,
[id, tenantId, templateKey, name, type, folderId, 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, {
id: template.id,
key: template.template_key,
name: template.name,
version: null,
draft: true
})
})
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}`))