diff --git a/docker-compose.yml b/docker-compose.yml index 2fa7220..cc8b9ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/services/updates-api/README.md b/services/updates-api/README.md index 1e8ba52..560d814 100644 --- a/services/updates-api/README.md +++ b/services/updates-api/README.md @@ -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 ` 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. diff --git a/services/updates-api/src/admin.html b/services/updates-api/src/admin.html new file mode 100644 index 0000000..c5e250d --- /dev/null +++ b/services/updates-api/src/admin.html @@ -0,0 +1,213 @@ + + + + + + W-MAKE Updates + + + +
+
+

Authelia · updates.w-make.com

+

Produkt-Updates

+
+ +
+
+ +
+ + + + + + +
+ +
+

Vorschau

+
+
+
+
+ + + +
+

+
+
+ + + diff --git a/services/updates-api/src/server.js b/services/updates-api/src/server.js index 0236680..16b7a5c 100644 --- a/services/updates-api/src/server.js +++ b/services/updates-api/src/server.js @@ -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'); diff --git a/services/updates-api/test/updates.test.js b/services/updates-api/test/updates.test.js index f41b7db..147b0f0 100644 --- a/services/updates-api/test/updates.test.js +++ b/services/updates-api/test/updates.test.js @@ -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); } +});