feat(updates): add Authelia-backed markdown editor
Product news is written at /admin behind Traefik forward-auth, while the public JSON and RSS feeds stay unauthenticated. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
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;
|
||||
@@ -48,7 +50,11 @@ function validatePayload(payload, partial = false) {
|
||||
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) { return Boolean(token) && req.headers.authorization === `Bearer ${token}`; }
|
||||
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]);
|
||||
@@ -88,6 +94,25 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
||||
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();
|
||||
@@ -139,9 +164,26 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
||||
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');
|
||||
}
|
||||
if (path.startsWith('/v1/admin/') && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user