feat: server-kant CMS voor de systeemmanager (v0.3.83-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
This commit is contained in:
parent
2fff8a3aa5
commit
aa4c5faaf9
7 changed files with 126 additions and 2 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.82-beta
|
0.3.83-beta
|
||||||
|
|
|
||||||
11
db/014_cms.sql
Normal file
11
db/014_cms.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
/* 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. */
|
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(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const PERMISSIONS = {
|
||||||
'assignments.manage': ['super', 'admin', 'teacher'],
|
'assignments.manage': ['super', 'admin', 'teacher'],
|
||||||
'progress.view': ['super', 'admin', 'teacher'],
|
'progress.view': ['super', 'admin', 'teacher'],
|
||||||
'shared.global.manage': ['super'],
|
'shared.global.manage': ['super'],
|
||||||
|
'content.manage': ['super'],
|
||||||
'shared.school.manage': ['super', 'admin', 'teacher'],
|
'shared.school.manage': ['super', 'admin', 'teacher'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
27
src/api.js
27
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 }));
|
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 -------------------------------------------------------------------
|
// ---- auth -------------------------------------------------------------------
|
||||||
app.post('/auth/login', {
|
app.post('/auth/login', {
|
||||||
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
|
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export const PERMISSIONS = {
|
||||||
'assignments.manage': ['super', 'admin', 'teacher'],
|
'assignments.manage': ['super', 'admin', 'teacher'],
|
||||||
'progress.view': ['super', 'admin', 'teacher'],
|
'progress.view': ['super', 'admin', 'teacher'],
|
||||||
'shared.global.manage': ['super'],
|
'shared.global.manage': ['super'],
|
||||||
|
'content.manage': ['super'],
|
||||||
'shared.school.manage': ['super', 'admin', 'teacher'],
|
'shared.school.manage': ['super', 'admin', 'teacher'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
84
test/cms.test.js
Normal file
84
test/cms.test.js
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue