import { createServer } from 'node:http'; import { mkdirSync, readFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { randomUUID } from 'node:crypto'; const ADMIN_HTML = readFileSync(new URL('./admin.html', import.meta.url), 'utf8'); const PRODUCTS = new Set(['batchmaker', 'standalone']); const STATUSES = new Set(['draft', 'published', 'archived']); const MAX_BODY = 64 * 1024; function now() { return new Date().toISOString(); } function json(res, status, value, headers = {}) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers }); res.end(JSON.stringify(value)); } function error(res, status, code, message) { json(res, status, { error: { code, message } }); } function validProduct(product) { return typeof product === 'string' && PRODUCTS.has(product); } function readBody(req) { return new Promise((resolve, reject) => { let data = ''; req.on('data', chunk => { data += chunk; if (Buffer.byteLength(data) > MAX_BODY) reject(Object.assign(new Error('body too large'), { code: 'BODY_TOO_LARGE' })); }); req.on('end', () => resolve(data)); req.on('error', reject); }); } function parsePayload(raw) { if (!raw) throw Object.assign(new Error('JSON body required'), { code: 'INVALID_JSON' }); try { return JSON.parse(raw); } catch { throw Object.assign(new Error('valid JSON required'), { code: 'INVALID_JSON' }); } } function validatePayload(payload, partial = false) { const fields = ['product', 'title', 'summary', 'body_markdown', 'link_url', 'published_at']; const out = {}; for (const field of fields) if (payload[field] !== undefined) out[field] = payload[field]; if (!partial || payload.product !== undefined) { if (!validProduct(out.product)) throw Object.assign(new Error('product must be batchmaker or standalone'), { code: 'INVALID_PRODUCT' }); } for (const field of ['title', 'summary']) { if (!partial || payload[field] !== undefined) { if (typeof out[field] !== 'string' || !out[field].trim() || out[field].length > 300) throw Object.assign(new Error(`${field} is required and limited to 300 characters`), { code: 'INVALID_FIELD' }); out[field] = out[field].trim(); } } if (out.body_markdown !== undefined && (typeof out.body_markdown !== 'string' || out.body_markdown.length > 20_000)) throw Object.assign(new Error('body_markdown must be at most 20000 characters'), { code: 'INVALID_FIELD' }); if (out.link_url !== undefined && out.link_url !== null && (typeof out.link_url !== 'string' || !/^https?:\/\//.test(out.link_url))) throw Object.assign(new Error('link_url must be an http(s) URL'), { code: 'INVALID_FIELD' }); if (out.published_at !== undefined && out.published_at !== null && Number.isNaN(Date.parse(out.published_at))) throw Object.assign(new Error('published_at must be an ISO date'), { code: 'INVALID_FIELD' }); return out; } function auth(req, token) { if (token && req.headers.authorization === `Bearer ${token}`) return true; const user = req.headers['remote-user']; return typeof user === 'string' && user.trim().length > 0; } function row(row) { return row ? { ...row } : null; } function xml(value) { return String(value ?? '').replace(/[<>&'\"]/g, character => ({ '<': '<', '>': '>', '&': '&', "'": ''', '\"': '"' })[character]); } function rss(items, baseUrl) { const entries = items.map(item => `${xml(item.title)}${xml(item.link_url || `${baseUrl}/v1/updates/${encodeURIComponent(item.slug)}`)}${xml(item.slug)}${new Date(item.published_at).toUTCString()}${xml(item.summary)}`).join(''); return `W-Make Batchmaker Updates${xml(baseUrl)}Updates für Batchmaker und Batchmaker Standalone${entries}`; } export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/updates.sqlite', adminToken = process.env.UPDATES_ADMIN_TOKEN || '' } = {}) { mkdirSync(dirname(dbPath), { recursive: true }); const db = new DatabaseSync(dbPath); db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;'); db.exec(`CREATE TABLE IF NOT EXISTS updates ( id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE, product TEXT NOT NULL CHECK (product IN ('batchmaker', 'standalone')), title TEXT NOT NULL, summary TEXT NOT NULL, body_markdown TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived')), published_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, link_url TEXT ); CREATE INDEX IF NOT EXISTS idx_updates_public ON updates(product, status, published_at DESC);`); function publishedFilter(product) { return product ? { sql: 'product = ? AND status = ? AND published_at IS NOT NULL AND published_at <= ?', args: [product, 'published', now()] } : { sql: 'status = ? AND published_at IS NOT NULL AND published_at <= ?', args: ['published', now()] }; } function getPublic(product, limit) { const filter = publishedFilter(product); return db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE ${filter.sql} ORDER BY published_at DESC, id DESC LIMIT ?`).all(...filter.args, limit).map(row); } function getBySlug(slug) { const f = publishedFilter(); return row(db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE slug = ? AND ${f.sql}`).get(slug, ...f.args)); } function adminList(product, status, limit) { const clauses = []; const args = []; if (product) { if (!validProduct(product)) throw Object.assign(new Error('product must be batchmaker or standalone'), { code: 'INVALID_PRODUCT' }); clauses.push('product = ?'); args.push(product); } if (status) { if (!STATUSES.has(status)) throw Object.assign(new Error('invalid status'), { code: 'INVALID_STATUS' }); clauses.push('status = ?'); args.push(status); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; return db.prepare(`SELECT * FROM updates ${where} ORDER BY updated_at DESC, id DESC LIMIT ?`).all(...args, limit).map(row); } function adminGet(id) { return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id)); } function adminCreate(payload) { const fields = validatePayload(payload); const created = now(); const slug = typeof payload.slug === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(payload.slug) ? payload.slug : `${fields.product}-${randomUUID()}`; db.prepare('INSERT INTO updates (slug, product, title, summary, body_markdown, status, published_at, created_at, updated_at, link_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(slug, fields.product, fields.title, fields.summary, fields.body_markdown || '', 'draft', null, created, created, fields.link_url || null); return row(db.prepare('SELECT * FROM updates WHERE slug = ?').get(slug)); } function adminPatch(id, payload) { const current = db.prepare('SELECT * FROM updates WHERE id = ?').get(id); if (!current) return null; const fields = validatePayload({ product: current.product, title: current.title, summary: current.summary, ...payload }, true); const updated = now(); db.prepare('UPDATE updates SET product = ?, title = ?, summary = ?, body_markdown = ?, link_url = ?, updated_at = ? WHERE id = ?').run(fields.product, fields.title, fields.summary, fields.body_markdown ?? current.body_markdown, fields.link_url ?? current.link_url, updated, id); return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id)); } function setStatus(id, status) { if (!STATUSES.has(status)) throw Object.assign(new Error('invalid status'), { code: 'INVALID_STATUS' }); const current = db.prepare('SELECT * FROM updates WHERE id = ?').get(id); if (!current) return null; const publishedAt = status === 'published' ? (current.published_at || now()) : current.published_at; db.prepare('UPDATE updates SET status = ?, published_at = ?, updated_at = ? WHERE id = ?').run(status, publishedAt, now(), id); return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id)); } async function handler(req, res) { const url = new URL(req.url, 'http://localhost'); const path = url.pathname; try { if (req.method === 'GET' && path === '/healthz') return json(res, 200, { status: 'ok' }); if (req.method === 'GET' && path === '/feed.xml') { const product = url.searchParams.get('product') || null; if (product && !validProduct(product)) return error(res, 400, 'INVALID_PRODUCT', 'product must be batchmaker or standalone'); return new Promise(resolve => { const items = getPublic(product, 50); res.writeHead(200, { 'content-type': 'application/rss+xml; charset=utf-8', 'cache-control': 'public, max-age=60' }); res.end(rss(items, `${url.origin}`)); resolve(); }); } if (req.method === 'GET' && path === '/v1/updates') { const product = url.searchParams.get('product') || null; if (product && !validProduct(product)) return error(res, 400, 'INVALID_PRODUCT', 'product must be batchmaker or standalone'); const requested = Number(url.searchParams.get('limit') || 10); const limit = Number.isInteger(requested) && requested >= 1 && requested <= 50 ? requested : 10; return json(res, 200, { data: getPublic(product, limit) }, { 'cache-control': 'public, max-age=60' }); } const slugMatch = path.match(/^\/v1\/updates\/([^/]+)$/); if (req.method === 'GET' && slugMatch) { const item = getBySlug(decodeURIComponent(slugMatch[1])); return item ? json(res, 200, { data: item }, { 'cache-control': 'public, max-age=60' }) : error(res, 404, 'NOT_FOUND', 'update not found'); } const isAdminUi = path === '/admin' || path === '/admin/'; if ((isAdminUi || path.startsWith('/v1/admin/')) && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required'); if (req.method === 'GET' && isAdminUi) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(ADMIN_HTML); return; } if (req.method === 'GET' && path === '/v1/admin/updates') { const product = url.searchParams.get('product') || null; const status = url.searchParams.get('status') || null; const requested = Number(url.searchParams.get('limit') || 50); const limit = Number.isInteger(requested) && requested >= 1 && requested <= 50 ? requested : 50; return json(res, 200, { data: adminList(product, status, limit) }, { 'cache-control': 'no-store' }); } if (req.method === 'POST' && path === '/v1/admin/updates') return json(res, 201, { data: adminCreate(parsePayload(await readBody(req))) }); const adminMatch = path.match(/^\/v1\/admin\/updates\/(\d+)(?:\/(publish|archive))?$/); if (adminMatch && req.method === 'GET' && !adminMatch[2]) { const item = adminGet(Number(adminMatch[1])); return item ? json(res, 200, { data: item }, { 'cache-control': 'no-store' }) : error(res, 404, 'NOT_FOUND', 'update not found'); } if (adminMatch && (req.method === 'PATCH' || req.method === 'POST')) { const item = adminMatch[2] ? setStatus(Number(adminMatch[1]), adminMatch[2] === 'publish' ? 'published' : 'archived') : adminPatch(Number(adminMatch[1]), parsePayload(await readBody(req))); return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'update not found'); } return error(res, 404, 'NOT_FOUND', 'route not found'); } catch (e) { if (e.code === 'BODY_TOO_LARGE') return error(res, 413, e.code, e.message); if (e.code && ['INVALID_JSON', 'INVALID_PRODUCT', 'INVALID_FIELD', 'INVALID_STATUS'].includes(e.code)) return error(res, 400, e.code, e.message); if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return error(res, 409, 'CONFLICT', 'slug already exists'); console.error(e); return error(res, 500, 'INTERNAL_ERROR', 'internal server error'); } } const server = createServer(handler); return { server, db, close: () => { db.close(); } }; } if (process.argv[1] && process.argv[1].endsWith('/src/server.js')) { const app = createApp(); const port = Number(process.env.PORT || 8080); app.server.listen(port, process.env.HOSTNAME || '0.0.0.0', () => console.log(`updates-api listening on ${port}`)); }