From 939c4ee68b212bb4b9490768b187f4a581522228 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 22:27:16 +0200 Subject: [PATCH] v0.3.26-beta: server-API voor de gedeelde bibliotheek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuwe migratie db/010: shared_library-tabel (school_id NULL = systeembreed, anders per school; kind board/anchor; folder als pad) - Routes: GET /shared/:kind (lijst, gefilterd op systeembreed + eigen school), GET /shared/:kind/:id (met data, voor kopiëren), POST (publiceren; scope global vereist systeemmanager, scope school staf met school), PATCH/DELETE per item en PATCH /shared/:kind/folder (prefix-rewrite, alleen als álle items onder het pad beheerd mogen worden) - Beheerregel: systeemmanager altijd, eigenaar altijd, schoolbeheerder voor items van de eigen school; leerlingen hebben geen toegang - Nieuwe permissies shared.global.manage / shared.school.manage (beide permissions.js); zes nieuwe servertests --- VERSION | 2 +- db/010_shared_library.sql | 17 +++++ public/js/core.js | 2 +- public/js/permissions.js | 2 + src/api.js | 127 ++++++++++++++++++++++++++++++++++++ src/permissions.js | 2 + test/shared-library.test.js | 111 +++++++++++++++++++++++++++++++ 7 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 db/010_shared_library.sql create mode 100644 test/shared-library.test.js diff --git a/VERSION b/VERSION index 38f9045..9683ce5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.25-beta +0.3.26-beta diff --git a/db/010_shared_library.sql b/db/010_shared_library.sql new file mode 100644 index 0000000..a89c3c9 --- /dev/null +++ b/db/010_shared_library.sql @@ -0,0 +1,17 @@ +-- Gedeelde bibliotheek: borden en ankerdiagrammen die de systeemmanager +-- (school_id NULL = systeembreed, voor iedereen zichtbaar) of stafleden van +-- een school (school_id gezet = alleen binnen die school zichtbaar) hebben +-- klaargezet. Andere gebruikers kopiëren een item naar hun eigen collectie; +-- er wordt niet live in gedeelde items gewerkt. folder is een pad met +-- "/"-scheiding, dezelfde conventie als de mappenverkenner. +CREATE TABLE IF NOT EXISTS shared_library ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + folder TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_shared_library ON shared_library(kind, school_id); diff --git a/public/js/core.js b/public/js/core.js index 15805d9..a00de88 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.25-beta"; +const VERSION = "0.3.26-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/permissions.js b/public/js/permissions.js index 4bf42c8..b57f4c9 100644 --- a/public/js/permissions.js +++ b/public/js/permissions.js @@ -14,6 +14,8 @@ const PERMISSIONS = { 'users.role.change': ['super'], 'assignments.manage': ['super', 'admin', 'teacher'], 'progress.view': ['super', 'admin', 'teacher'], + 'shared.global.manage': ['super'], + 'shared.school.manage': ['super', 'admin', 'teacher'], }; const CREATABLE_ROLES = { diff --git a/src/api.js b/src/api.js index cd826bb..166357f 100644 --- a/src/api.js +++ b/src/api.js @@ -730,6 +730,133 @@ export default async function api(app) { [c.id, PROGRESS_LIMIT])).rows; return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) }; }); + + // ---- gedeelde bibliotheek -------------------------------------------------- + // Systeembreed (school_id NULL, gepubliceerd door de systeemmanager, voor + // iedereen zichtbaar) of per school (alleen zichtbaar binnen die school). + // Andere gebruikers KOPIËREN een item naar hun eigen collectie; er wordt + // niet live in gedeelde items gewerkt. Beheren (hernoemen/verplaatsen/ + // verwijderen) mag de systeemmanager altijd, de eigenaar altijd, en de + // schoolbeheerder voor items van de eigen school. + const SHARED_KINDS = ['board', 'anchor']; + const sharedStaff = (req, reply) => { + need(req, reply); + if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); } + }; + const canManageShared = (req, item) => + req.user.allRoles.includes('super') + || Number(item.owner_id) === Number(req.user.id) + || (item.school_id != null && req.user.allRoles.includes('admin') + && Number(item.school_id) === Number(req.user.school_id)); + const sharedItemVisible = (req, item) => + req.user.allRoles.includes('super') + || item.school_id == null + || Number(item.school_id) === Number(req.user.school_id); + const validSharedName = (n) => typeof n === 'string' && n.trim() && n.trim().length <= 40; + const validSharedFolder = (f) => typeof f === 'string' && f.length <= 200 && !f.includes('//') && !/[\x00-\x1f]/.test(f); + const describeShared = (req, it) => ({ + id: Number(it.id), + name: it.name, + folder: it.folder || '', + scope: it.school_id == null ? 'global' : 'school', + ownerName: it.owner_name || null, + canManage: canManageShared(req, it), + }); + + app.get('/shared/:kind', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const rows = req.user.allRoles.includes('super') + ? (await pool.query( + `SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, u.display_name AS owner_name + FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id + WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows + : (await pool.query( + `SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, u.display_name AS owner_name + FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id + WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2) + ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows; + return { items: rows.map((it) => describeShared(req, it)) }; + }); + app.get('/shared/:kind/:id', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0]; + if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend'); + return { item: { ...describeShared(req, it), data: it.data } }; + }); + app.post('/shared/:kind', { bodyLimit: 6 * 1024 * 1024 }, async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const { name, folder, data, scope } = req.body ?? {}; + if (!validSharedName(name)) return fail(reply, 400, 'ongeldige naam'); + if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map'); + if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige data'); + if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 5 * 1024 * 1024) return fail(reply, 413, 'data te groot'); + let schoolId = null; + if (scope === 'global') { + need(req, reply, PERMISSIONS['shared.global.manage']); + } else if (scope === 'school') { + need(req, reply, PERMISSIONS['shared.school.manage']); + if (req.user.school_id == null) return fail(reply, 400, 'geen school gekoppeld aan dit account'); + schoolId = req.user.school_id; + } else return fail(reply, 400, 'ongeldige scope'); + const r = await pool.query( + `INSERT INTO shared_library (school_id, owner_id, kind, folder, name, data) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + [schoolId, req.user.id, kind, folder || '', name.trim(), data]); + return { ok: true, id: Number(r.rows[0].id) }; + }); + // map-prefix-rewrite (map hernoemen/verplaatsen in de gedeelde boom); mag + // alleen als de gebruiker ALLE items onder het pad mag beheren + app.patch('/shared/:kind/folder', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const { scope, from, to } = req.body ?? {}; + if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope'); + if (!validSharedFolder(from) || !from || !validSharedFolder(to) || !to) return fail(reply, 400, 'ongeldige map'); + const rows = scope === 'global' + ? (await pool.query( + `SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`, + [kind, from, from + '/%'])).rows + : (await pool.query( + `SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`, + [kind, req.user.school_id, from, from + '/%'])).rows; + if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map'); + await pool.query( + `UPDATE shared_library SET folder = $1 || substr(folder, $2) + WHERE id = ANY($3)`, + [to, from.length + 1, rows.map((it) => it.id)]); + return { ok: true }; + }); + app.patch('/shared/:kind/:id', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0]; + if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend'); + if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten'); + const { name, folder } = req.body ?? {}; + if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam'); + if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map'); + if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]); + if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]); + return { ok: true }; + }); + app.delete('/shared/:kind/:id', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0]; + if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend'); + if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM shared_library WHERE id = $1', [it.id]); + return { ok: true }; + }); } // Eerste super-beheerder aanmaken als die nog niet bestaat. diff --git a/src/permissions.js b/src/permissions.js index d086646..062e5cd 100644 --- a/src/permissions.js +++ b/src/permissions.js @@ -16,6 +16,8 @@ export const PERMISSIONS = { 'users.role.change': ['super'], 'assignments.manage': ['super', 'admin', 'teacher'], 'progress.view': ['super', 'admin', 'teacher'], + 'shared.global.manage': ['super'], + 'shared.school.manage': ['super', 'admin', 'teacher'], }; // Welke rol een account met welke rol mag aanmaken. diff --git a/test/shared-library.test.js b/test/shared-library.test.js new file mode 100644 index 0000000..5cdf64c --- /dev/null +++ b/test/shared-library.test.js @@ -0,0 +1,111 @@ +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'; + +// App met een instelbare ingelogde gebruiker en vastgelegde queries; de +// shared_library-select levert een vaste mix van systeembrede en school-items. +function makeApp(user, sharedRows = []) { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.includes('FROM shared_library')) return { rows: sharedRows }; + if (sql.includes('INSERT INTO shared_library') && sql.includes('RETURNING id')) return { rows: [{ id: 42 }] }; + 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 teacher = { id: 7, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; +const pupil = { id: 9, username: 'kind', role: 'pupil', school_id: 2, class_id: 5, data: {}, data_rev: 0 }; + +test('GET /shared/:kind filtert op systeembreed + eigen school voor staf', async () => { + const { app, calls } = await makeApp(teacher, [ + { id: 1, school_id: null, owner_id: 1, folder: 'Rekenen', name: 'Breuken', owner_name: 'SM' }, + ]); + const res = await app.inject({ method: 'GET', url: '/api/shared/board', cookies }); + assert.equal(res.statusCode, 200, res.body); + const items = res.json().items; + assert.equal(items[0].scope, 'global'); + assert.equal(items[0].canManage, false); + const q = calls.find((c) => c.sql.includes('FROM shared_library')); + assert.ok(q.sql.includes('school_id IS NULL OR s.school_id = $2')); + assert.equal(q.params[1], 2); + await app.close(); +}); + +test('leerlingen hebben geen toegang tot de gedeelde bibliotheek', async () => { + const { app } = await makeApp(pupil); + const res = await app.inject({ method: 'GET', url: '/api/shared/board', cookies }); + assert.equal(res.statusCode, 403); + await app.close(); +}); + +test('publiceren met scope global vereist systeemmanager', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ method: 'POST', url: '/api/shared/board', cookies, + payload: { name: 'Breuken', folder: 'Rekenen', data: { widgets: [] }, scope: 'global' } }); + assert.equal(res.statusCode, 403); + const { app: superApp, calls } = await makeApp(superUser); + const ok = await superApp.inject({ method: 'POST', url: '/api/shared/board', cookies, + payload: { name: 'Breuken', folder: 'Rekenen', data: { widgets: [] }, scope: 'global' } }); + assert.equal(ok.statusCode, 200, ok.body); + const ins = calls.find((c) => c.sql.includes('INSERT INTO shared_library')); + assert.equal(ins.params[0], null); // school_id NULL = systeembreed + await superApp.close(); + await app.close(); +}); + +test('publiceren met scope school gebruikt de eigen school', async () => { + const { app, calls } = await makeApp(teacher); + const res = await app.inject({ method: 'POST', url: '/api/shared/anchor', cookies, + payload: { name: 'water', data: { cells: {} }, scope: 'school' } }); + assert.equal(res.statusCode, 200, res.body); + const ins = calls.find((c) => c.sql.includes('INSERT INTO shared_library')); + assert.equal(ins.params[0], 2); + assert.equal(ins.params[2], 'anchor'); + await app.close(); +}); + +test('beheren mag alleen door eigenaar, schoolbeheerder of systeemmanager', async () => { + // teacher (id 7) probeert andermans school-item te verwijderen + const foreignItem = { id: 5, school_id: 2, owner_id: 8, kind: 'board', folder: '', name: 'X', data: {} }; + const { app } = await makeApp(teacher, [foreignItem]); + const res = await app.inject({ method: 'DELETE', url: '/api/shared/board/5', cookies }); + assert.equal(res.statusCode, 403); + // eigenaar mag wel + const ownItem = { ...foreignItem, owner_id: 7 }; + const { app: app2 } = await makeApp(teacher, [ownItem]); + const res2 = await app2.inject({ method: 'DELETE', url: '/api/shared/board/5', cookies }); + assert.equal(res2.statusCode, 200, res2.body); + await app2.close(); + await app.close(); +}); + +test('map-rewrite weigert als niet alle items beheerd mogen worden', async () => { + const rows = [ + { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {} }, + { id: 6, school_id: 2, owner_id: 8, kind: 'board', folder: 'Taal/Sub', name: 'B', data: {} }, + ]; + const { app } = await makeApp(teacher, rows); + const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/folder', cookies, + payload: { scope: 'school', from: 'Taal', to: 'Lezen' } }); + assert.equal(res.statusCode, 403); + await app.close(); +});