Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
7 changed files with 261 additions and 2 deletions
Showing only changes of commit 939c4ee68b - Show all commits

View file

@ -1 +1 @@
0.3.25-beta 0.3.26-beta

17
db/010_shared_library.sql Normal file
View file

@ -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);

View file

@ -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.25-beta"; const VERSION = "0.3.26-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;

View file

@ -14,6 +14,8 @@ const PERMISSIONS = {
'users.role.change': ['super'], 'users.role.change': ['super'],
'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.school.manage': ['super', 'admin', 'teacher'],
}; };
const CREATABLE_ROLES = { const CREATABLE_ROLES = {

View file

@ -730,6 +730,133 @@ export default async function api(app) {
[c.id, PROGRESS_LIMIT])).rows; [c.id, PROGRESS_LIMIT])).rows;
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) }; 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. // Eerste super-beheerder aanmaken als die nog niet bestaat.

View file

@ -16,6 +16,8 @@ export const PERMISSIONS = {
'users.role.change': ['super'], 'users.role.change': ['super'],
'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.school.manage': ['super', 'admin', 'teacher'],
}; };
// Welke rol een account met welke rol mag aanmaken. // Welke rol een account met welke rol mag aanmaken.

111
test/shared-library.test.js Normal file
View file

@ -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();
});