Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
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";
|
"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;
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
127
src/api.js
127
src/api.js
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
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