From aa4c5faaf9875b08ec7d2816cd2acdfc2ac62083 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 19:29:08 +0200 Subject: [PATCH] feat: server-kant CMS voor de systeemmanager (v0.3.83-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migratie 014: site_content (sleutel/waarde, JSONB) voor site-inhoud die zonder release aanpasbaar moet zijn - GET /content (publiek): welkomstwoord, update-melding en widget-uitleg in één antwoord - het inlogscherm toont het welkomstwoord al vóór het inloggen - PUT /admin/content/:key (alleen systeemmanager, nieuwe permissie content.manage): upsert per toegestane sleutel (welcome/announcement/widgetInfo) met maat-limieten - 4 servertests: publiek lezen, upsert door super, 403/401 voor anderen, whitelist/vorm/omvang-validatie Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- db/014_cms.sql | 11 ++++++ public/js/core.js | 2 +- public/js/permissions.js | 1 + src/api.js | 27 +++++++++++++ src/permissions.js | 1 + test/cms.test.js | 84 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 db/014_cms.sql create mode 100644 test/cms.test.js diff --git a/VERSION b/VERSION index db09777..f91e315 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.82-beta +0.3.83-beta diff --git a/db/014_cms.sql b/db/014_cms.sql new file mode 100644 index 0000000..10865bd --- /dev/null +++ b/db/014_cms.sql @@ -0,0 +1,11 @@ +-- v0.3.83-beta: CMS voor de systeemmanager. +-- Kleine sleutel/waarde-opslag voor site-inhoud die zonder release aanpasbaar +-- moet zijn: welkomstwoord (inlogscherm), update-meldingen (banner) en +-- aanvullende widget-uitleg (galerij). De waarde is JSONB zodat elke sleutel +-- zijn eigen vorm heeft (bv. { nl, en } of { actief, tekst per taal, id }). +CREATE TABLE IF NOT EXISTS site_content ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL +); diff --git a/public/js/core.js b/public/js/core.js index 530ac94..4e7e6a9 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.82-beta"; +const VERSION = "0.3.83-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/permissions.js b/public/js/permissions.js index b57f4c9..f0eddeb 100644 --- a/public/js/permissions.js +++ b/public/js/permissions.js @@ -15,6 +15,7 @@ const PERMISSIONS = { 'assignments.manage': ['super', 'admin', 'teacher'], 'progress.view': ['super', 'admin', 'teacher'], 'shared.global.manage': ['super'], + 'content.manage': ['super'], 'shared.school.manage': ['super', 'admin', 'teacher'], }; diff --git a/src/api.js b/src/api.js index dae924d..d3b4f10 100644 --- a/src/api.js +++ b/src/api.js @@ -82,6 +82,33 @@ export default async function api(app) { return r.rows.map((s) => ({ id: Number(s.id), name: s.name })); }); + // ---- site-inhoud (CMS van de systeemmanager) -------------------------------- + // Kleine sleutel/waarde-opslag: welkomstwoord (inlogscherm), update-melding + // (banner voor alle gebruikers) en aanvullende widget-uitleg (galerij). + // Lezen is publiek (het inlogscherm toont het welkomstwoord al vóór het + // inloggen); schrijven kan alleen de systeemmanager, per toegestane sleutel + // en met een maat-limiet zodat dit nooit een opslagplaats voor blobs wordt. + const CONTENT_KEYS = { welcome: 8 * 1024, announcement: 8 * 1024, widgetInfo: 64 * 1024 }; + app.get('/content', async () => { + const r = await pool.query('SELECT key, value FROM site_content'); + const out = {}; + r.rows.forEach((row) => { out[row.key] = row.value; }); + return out; + }); + app.put('/admin/content/:key', async (req, reply) => { + need(req, reply, PERMISSIONS['content.manage']); + const key = req.params.key; + if (!(key in CONTENT_KEYS)) return fail(reply, 404, 'onbekende inhoud'); + const value = req.body ?? {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(reply, 400, 'ongeldige inhoud'); + if (Buffer.byteLength(JSON.stringify(value), 'utf8') > CONTENT_KEYS[key]) return fail(reply, 413, 'inhoud te groot'); + await pool.query( + `INSERT INTO site_content (key, value, updated_at, updated_by) VALUES ($1, $2, now(), $3) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now(), updated_by = $3`, + [key, value, req.user.id]); + return { ok: true }; + }); + // ---- auth ------------------------------------------------------------------- app.post('/auth/login', { config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } }, diff --git a/src/permissions.js b/src/permissions.js index 062e5cd..733f803 100644 --- a/src/permissions.js +++ b/src/permissions.js @@ -17,6 +17,7 @@ export const PERMISSIONS = { 'assignments.manage': ['super', 'admin', 'teacher'], 'progress.view': ['super', 'admin', 'teacher'], 'shared.global.manage': ['super'], + 'content.manage': ['super'], 'shared.school.manage': ['super', 'admin', 'teacher'], }; diff --git a/test/cms.test.js b/test/cms.test.js new file mode 100644 index 0000000..b6e4a22 --- /dev/null +++ b/test/cms.test.js @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +// Gemockte pool zoals in schools.test.js: sessie-join levert `user`, +// site_content-queries worden per test beantwoord/vastgelegd. +function makeApp(user, rows = []) { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: user ? [user] : [] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.includes('FROM site_content')) return { rows }; + return { rows: [] }; + }, + }; + return (async () => { + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', pool); + await app.register(api, { prefix: '/api' }); + await app.ready(); + return { app, calls }; + })(); +} + +const cookies = { teach_session: 'x'.repeat(64) }; +const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 }; +const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; + +test('site-inhoud is publiek leesbaar als sleutel/waarde-object', async () => { + const { app } = await makeApp(null, [ + { key: 'welcome', value: { nl: 'Welkom!', en: 'Welcome!' } }, + { key: 'announcement', value: { active: true, nl: 'Update', en: 'Update', id: 7 } }, + ]); + const res = await app.inject({ url: '/api/content' }); + assert.equal(res.statusCode, 200, res.body); + const body = res.json(); + assert.equal(body.welcome.nl, 'Welkom!'); + assert.equal(body.announcement.active, true); + await app.close(); +}); + +test('systeemmanager slaat inhoud op via upsert', async () => { + const { app, calls } = await makeApp(superUser); + const res = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, + payload: { nl: 'Hallo school!', en: 'Hello school!' } }); + assert.equal(res.statusCode, 200, res.body); + const upsert = calls.find((c) => c.sql.includes('INSERT INTO site_content')); + assert.ok(upsert.sql.includes('ON CONFLICT (key) DO UPDATE')); + assert.equal(upsert.params[0], 'welcome'); + assert.equal(upsert.params[1].nl, 'Hallo school!'); + assert.equal(upsert.params[2], 1); + await app.close(); +}); + +test('schoolbeheerder en gasten mogen geen inhoud schrijven', async () => { + const { app } = await makeApp(admin); + const res = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, + payload: { nl: 'x' } }); + assert.equal(res.statusCode, 403); + const { app: app2 } = await makeApp(null); + const res2 = await app2.inject({ method: 'PUT', url: '/api/admin/content/welcome', + payload: { nl: 'x' } }); + assert.equal(res2.statusCode, 401); + await app.close(); await app2.close(); +}); + +test('alleen bekende sleutels, objecten en begrensde omvang worden geaccepteerd', async () => { + const { app } = await makeApp(superUser); + const unknown = await app.inject({ method: 'PUT', url: '/api/admin/content/hack', cookies, payload: { a: 1 } }); + assert.equal(unknown.statusCode, 404); + const arr = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, payload: [1, 2] }); + assert.equal(arr.statusCode, 400); + const huge = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, + payload: { nl: 'x'.repeat(9 * 1024) } }); + assert.equal(huge.statusCode, 413); + await app.close(); +});