Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
7 changed files with 261 additions and 2 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.25-beta
|
||||
0.3.26-beta
|
||||
|
|
|
|||
17
db/010_shared_library.sql
Normal file
17
db/010_shared_library.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
127
src/api.js
127
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
111
test/shared-library.test.js
Normal file
111
test/shared-library.test.js
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue