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:
Jan Wagner
2026-08-16 11:27:09 +02:00
co-authored by Cursor
parent 0482b780f4
commit 7e8313c96e
5 changed files with 306 additions and 4 deletions
+5 -1
View File
@@ -42,7 +42,11 @@ The service intentionally has no CMS, user system, comments, or direct access to
ponytail: Keep the API contract stable; migrate to PostgreSQL/OIDC only when measured write volume or multi-author editing justifies the additional operational surface.
## Current integration boundary
## Admin panel
Open `https://updates.w-make.com/admin` after Authelia login (`auth.w-make.com`). Traefik protects `/admin` and `/v1/admin`; public JSON and RSS stay open.
The editor accepts Authelia `Remote-User` or `Authorization: Bearer <token>` for scripted writes from the Docker network.
The portfolio is wired as the first client. Batchmaker and Standalone still need their own UI client changes in their canonical repositories after the runtime/deployment URLs are confirmed.
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>W-MAKE Updates</title>
<style>
:root {
--ink: #12100e;
--raised: #1b1814;
--paper: #f4efe4;
--copper: #c9843a;
--mute: #9a9184;
--line: rgba(244, 239, 228, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--ink);
color: var(--paper);
font: 16px/1.5 ui-sans-serif, system-ui, sans-serif;
}
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem; }
header { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; border-bottom: 1px solid var(--line); }
h1 { font-size: 1.4rem; font-weight: 550; margin: 0; }
.kicker { color: var(--copper); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; }
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; }
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
.list { border-right: 1px solid var(--line); padding-right: 1rem; }
.item {
display: block; width: 100%; text-align: left;
background: transparent; color: inherit; border: 0; border-bottom: 1px solid var(--line);
padding: 0.8rem 0; cursor: pointer;
}
.item.active { color: var(--copper); }
.status { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--mute); }
label { display: grid; gap: 0.35rem; margin-bottom: 0.9rem; font-size: 0.8rem; color: var(--mute); letter-spacing: 0.08em; text-transform: uppercase; }
input, select, textarea {
width: 100%; background: var(--raised); color: var(--paper);
border: 1px solid var(--line); padding: 0.65rem 0.75rem; font: inherit;
}
textarea { min-height: 220px; font-family: ui-monospace, SFMono-Regular, monospace; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 860px) { .split { grid-template-columns: 1fr; } }
.preview {
min-height: 220px; border: 1px solid var(--line); padding: 0.75rem;
background: var(--raised); color: var(--paper);
}
.preview h1, .preview h2, .preview h3 { margin: 0.4rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
button {
border: 1px solid var(--copper); background: var(--copper); color: var(--ink);
padding: 0.55rem 0.9rem; cursor: pointer; font: inherit;
}
button.ghost { background: transparent; color: var(--paper); border-color: var(--line); }
.msg { margin-top: 1rem; color: var(--copper); min-height: 1.4rem; }
</style>
</head>
<body>
<header>
<div>
<p class="kicker">Authelia · updates.w-make.com</p>
<h1>Produkt-Updates</h1>
</div>
<button class="ghost" id="new-btn" type="button">Neues Update</button>
</header>
<main class="layout">
<aside class="list">
<p class="kicker">Bestand</p>
<div id="items"></div>
</aside>
<form id="form">
<input type="hidden" name="id">
<label>Produkt
<select name="product">
<option value="batchmaker">W-Make Batch</option>
<option value="standalone">Batchmaker Studio</option>
</select>
</label>
<label>Titel
<input name="title" required maxlength="300">
</label>
<label>Kurztext
<input name="summary" required maxlength="300">
</label>
<label>Slug (optional, nur beim Anlegen)
<input name="slug" maxlength="80" placeholder="batchmaker-schicht-handover">
</label>
<label>Link (optional)
<input name="link_url" type="url" placeholder="https://batch.w-make.com">
</label>
<div class="split">
<label>Markdown
<textarea name="body_markdown" id="body"></textarea>
</label>
<div>
<p class="kicker">Vorschau</p>
<div class="preview" id="preview"></div>
</div>
</div>
<div class="actions">
<button type="submit">Speichern</button>
<button class="ghost" id="publish-btn" type="button">Publizieren</button>
<button class="ghost" id="archive-btn" type="button">Archivieren</button>
</div>
<p class="msg" id="msg"></p>
</form>
</main>
<script>
const itemsEl = document.getElementById("items");
const form = document.getElementById("form");
const msg = document.getElementById("msg");
const preview = document.getElementById("preview");
const body = document.getElementById("body");
let currentId = "";
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char]));
}
function renderMarkdown(source) {
const escaped = escapeHtml(source || "");
return escaped
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^\- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>")
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noreferrer">$1</a>')
.replace(/\n\n/g, "</p><p>")
.replace(/\n/g, "<br>");
}
function setMsg(text) { msg.textContent = text || ""; }
function fill(item) {
currentId = item?.id ? String(item.id) : "";
form.id.value = currentId;
form.product.value = item?.product || "batchmaker";
form.title.value = item?.title || "";
form.summary.value = item?.summary || "";
form.slug.value = item?.slug || "";
form.link_url.value = item?.link_url || "";
body.value = item?.body_markdown || "";
preview.innerHTML = `<p>${renderMarkdown(body.value)}</p>`;
for (const node of itemsEl.querySelectorAll(".item")) {
node.classList.toggle("active", node.dataset.id === currentId);
}
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "content-type": "application/json", ...(options.headers || {}) },
...options,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error?.message || `HTTP ${response.status}`);
return payload;
}
async function refresh() {
const payload = await api("/v1/admin/updates?limit=50");
itemsEl.innerHTML = payload.data.map((item) => (
`<button class="item${String(item.id) === currentId ? " active" : ""}" data-id="${item.id}" type="button">
<span class="status">${escapeHtml(item.product)} · ${escapeHtml(item.status)}</span>
<div>${escapeHtml(item.title)}</div>
</button>`
)).join("") || "<p class='status'>Noch keine Updates.</p>";
for (const node of itemsEl.querySelectorAll(".item")) {
node.addEventListener("click", async () => {
const item = payload.data.find((entry) => String(entry.id) === node.dataset.id);
fill(item);
});
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const payload = {
product: form.product.value,
title: form.title.value,
summary: form.summary.value,
body_markdown: body.value,
link_url: form.link_url.value || null,
};
if (!currentId && form.slug.value.trim()) payload.slug = form.slug.value.trim();
const creating = !currentId;
const saved = creating
? await api("/v1/admin/updates", { method: "POST", body: JSON.stringify(payload) })
: await api(`/v1/admin/updates/${currentId}`, { method: "PATCH", body: JSON.stringify(payload) });
fill(saved.data);
await refresh();
setMsg(creating ? "Draft angelegt." : "Gespeichert.");
} catch (error) {
setMsg(error.message);
}
});
async function setStatus(action) {
if (!currentId) { setMsg("Zuerst speichern."); return; }
try {
const saved = await api(`/v1/admin/updates/${currentId}/${action}`, { method: "POST" });
fill(saved.data);
await refresh();
setMsg(action === "publish" ? "Veröffentlicht." : "Archiviert.");
} catch (error) {
setMsg(error.message);
}
}
document.getElementById("new-btn").addEventListener("click", () => fill(null));
document.getElementById("publish-btn").addEventListener("click", () => setStatus("publish"));
document.getElementById("archive-btn").addEventListener("click", () => setStatus("archive"));
body.addEventListener("input", () => { preview.innerHTML = `<p>${renderMarkdown(body.value)}</p>`; });
refresh().catch((error) => setMsg(error.message));
</script>
</body>
</html>
+45 -3
View File
@@ -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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '\"': '&quot;' })[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');
+32
View File
@@ -52,3 +52,35 @@ test('rejects oversized request bodies', async () => {
assert.equal(response.status, 413);
} finally { await teardown(app); }
});
test('accepts Authelia Remote-User for admin writes and lists drafts', async () => {
const { app, request } = await setup();
try {
const created = await request('/v1/admin/updates', {
method: 'POST',
headers: { 'content-type': 'application/json', 'remote-user': 'jan' },
body: JSON.stringify(draft),
});
assert.equal(created.status, 201);
const listed = await request('/v1/admin/updates', { headers: { 'remote-user': 'jan' } });
assert.equal(listed.status, 200);
const payload = await listed.json();
assert.equal(payload.data.length, 1);
assert.equal(payload.data[0].status, 'draft');
assert.equal(payload.data[0].title, 'Standalone 1');
} finally { await teardown(app); }
});
test('serves the admin editor only to authenticated operators', async () => {
const { app, request } = await setup();
try {
const denied = await request('/admin');
assert.equal(denied.status, 401);
const page = await request('/admin', { headers: { 'remote-user': 'jan' } });
assert.equal(page.status, 200);
assert.match(page.headers.get('content-type'), /text\/html/);
const html = await page.text();
assert.match(html, /body_markdown/);
assert.match(html, /Batchmaker/);
} finally { await teardown(app); }
});