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:
@@ -58,11 +58,22 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.w-make-updates.rule=Host(`updates.w-make.com`)"
|
||||
- "traefik.http.routers.w-make-updates.priority=10"
|
||||
- "traefik.http.routers.w-make-updates.entrypoints=websecure"
|
||||
- "traefik.http.routers.w-make-updates.tls=true"
|
||||
- "traefik.http.routers.w-make-updates.tls.certresolver=http_resolver"
|
||||
- "traefik.http.routers.w-make-updates.middlewares=default@file,crowdsec-bouncer-plugin@file"
|
||||
- "traefik.http.services.w-make-updates.loadbalancer.server.port=8080"
|
||||
- "traefik.http.middlewares.wmake-updates-authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://auth.w-make.com"
|
||||
- "traefik.http.middlewares.wmake-updates-authelia.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.wmake-updates-authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups"
|
||||
- "traefik.http.routers.w-make-updates-admin.rule=Host(`updates.w-make.com`) && (PathPrefix(`/admin`) || PathPrefix(`/v1/admin`))"
|
||||
- "traefik.http.routers.w-make-updates-admin.priority=200"
|
||||
- "traefik.http.routers.w-make-updates-admin.entrypoints=websecure"
|
||||
- "traefik.http.routers.w-make-updates-admin.tls=true"
|
||||
- "traefik.http.routers.w-make-updates-admin.tls.certresolver=http_resolver"
|
||||
- "traefik.http.routers.w-make-updates-admin.middlewares=default@file,crowdsec-bouncer-plugin@file,wmake-updates-authelia@docker"
|
||||
- "traefik.http.routers.w-make-updates-admin.service=w-make-updates"
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user