diff --git a/VERSION b/VERSION index 9c64e37..29aaad3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.27-beta +0.3.28-beta diff --git a/db/011_shared_readonly.sql b/db/011_shared_readonly.sql new file mode 100644 index 0000000..c95b4f6 --- /dev/null +++ b/db/011_shared_readonly.sql @@ -0,0 +1,6 @@ +-- Alleen-lezen-vlag voor gedeelde items: een vergrendeld school-item kan ook +-- door de eigenaar niet meer gewijzigd worden, alleen nog door de +-- schoolbeheerder of de systeemmanager (die de vlag ook zetten/weghalen). +-- Systeembrede items zijn impliciet al alleen-lezen voor iedereen behalve de +-- systeemmanager (beheerregel), de vlag is daar niet voor nodig. +ALTER TABLE shared_library ADD COLUMN IF NOT EXISTS readonly BOOLEAN NOT NULL DEFAULT false; diff --git a/public/js/core.js b/public/js/core.js index 6887104..b8db1c1 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.27-beta"; +const VERSION = "0.3.28-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/src/api.js b/src/api.js index 166357f..d5e8238 100644 --- a/src/api.js +++ b/src/api.js @@ -743,9 +743,17 @@ export default async function api(app) { need(req, reply); if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); } }; + // Vergrendeld (readonly) school-item: ook de eigenaar kan er niet meer bij, + // alleen de schoolbeheerder en de systeemmanager nog. 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)) + || (Number(item.owner_id) === Number(req.user.id) && !item.readonly); + // Wie mag de alleen-lezen-vlag zetten/weghalen: de systeemmanager altijd, + // de schoolbeheerder voor items van de eigen school (niet de leerkracht). + const canLockShared = (req, item) => + req.user.allRoles.includes('super') || (item.school_id != null && req.user.allRoles.includes('admin') && Number(item.school_id) === Number(req.user.school_id)); const sharedItemVisible = (req, item) => @@ -760,7 +768,9 @@ export default async function api(app) { folder: it.folder || '', scope: it.school_id == null ? 'global' : 'school', ownerName: it.owner_name || null, + readonly: !!it.readonly, canManage: canManageShared(req, it), + canLock: canLockShared(req, it), }); app.get('/shared/:kind', async (req, reply) => { @@ -769,11 +779,11 @@ export default async function api(app) { 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 + `SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, 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 + `SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, 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; @@ -810,15 +820,19 @@ export default async function api(app) { [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 + // map-prefix-rewrite (map hernoemen/verplaatsen: {from, to}) of de hele map + // op alleen-lezen zetten ({from, readonly}); rewrite mag alleen als de + // gebruiker ALLE items onder het pad mag beheren, vergrendelen alleen door + // wie de vlag op alle items mag zetten (schoolbeheerder/systeemmanager) 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 ?? {}; + const { scope, from, to, readonly } = 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'); + if (!validSharedFolder(from) || !from) return fail(reply, 400, 'ongeldige map'); + if (to === undefined && typeof readonly !== 'boolean') return fail(reply, 400, 'niets te doen'); + if (to !== undefined && (!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)`, @@ -826,11 +840,17 @@ export default async function api(app) { : (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)]); + if (to !== undefined){ + 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)]); + } + if (typeof readonly === 'boolean'){ + if (rows.some((it) => !canLockShared(req, it))) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen'); + await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = ANY($2)', [readonly, rows.map((it) => it.id)]); + } return { ok: true }; }); app.patch('/shared/:kind/:id', async (req, reply) => { @@ -839,12 +859,19 @@ export default async function api(app) { 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]); + const { name, folder, readonly } = req.body ?? {}; + if (name !== undefined || folder !== undefined){ + if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten'); + 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]); + } + if (readonly !== undefined){ + if (typeof readonly !== 'boolean') return fail(reply, 400, 'ongeldige waarde'); + if (!canLockShared(req, it)) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen'); + await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = $2', [readonly, it.id]); + } return { ok: true }; }); app.delete('/shared/:kind/:id', async (req, reply) => { diff --git a/test/shared-library.test.js b/test/shared-library.test.js index 5cdf64c..2018c6b 100644 --- a/test/shared-library.test.js +++ b/test/shared-library.test.js @@ -98,6 +98,54 @@ test('beheren mag alleen door eigenaar, schoolbeheerder of systeemmanager', asyn await app.close(); }); +test('een vergrendeld school-item is ook voor de eigenaar alleen-lezen', async () => { + const lockedOwn = { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: '', name: 'X', data: {}, readonly: true }; + const { app } = await makeApp(teacher, [lockedOwn]); + const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies, + payload: { name: 'Nieuwe naam' } }); + assert.equal(res.statusCode, 403); + // kopiëren (GET met data) blijft gewoon werken + const get = await app.inject({ method: 'GET', url: '/api/shared/board/5', cookies }); + assert.equal(get.statusCode, 200); + assert.equal(get.json().item.readonly, true); + assert.equal(get.json().item.canManage, false); + await app.close(); +}); + +test('alleen schoolbeheerder/systeemmanager mag de alleen-lezen-vlag zetten', async () => { + const ownItem = { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: '', name: 'X', data: {}, readonly: false }; + // eigenaar-leerkracht mag de vlag NIET zetten + const { app } = await makeApp(teacher, [ownItem]); + const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies, + payload: { readonly: true } }); + assert.equal(res.statusCode, 403); + await app.close(); + // schoolbeheerder van dezelfde school wel + const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; + const { app: app2, calls } = await makeApp(admin, [ownItem]); + const res2 = await app2.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies, + payload: { readonly: true } }); + assert.equal(res2.statusCode, 200, res2.body); + const upd = calls.find((c) => c.sql.includes('SET readonly')); + assert.deepEqual(upd.params, [true, 5]); + await app2.close(); +}); + +test('map vergrendelen zet de vlag op alle items onder het pad', async () => { + const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; + const rows = [ + { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {}, readonly: false }, + { id: 6, school_id: 2, owner_id: 8, kind: 'board', folder: 'Taal/Sub', name: 'B', data: {}, readonly: false }, + ]; + const { app, calls } = await makeApp(admin, rows); + const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/folder', cookies, + payload: { scope: 'school', from: 'Taal', readonly: true } }); + assert.equal(res.statusCode, 200, res.body); + const upd = calls.find((c) => c.sql.includes('SET readonly')); + assert.deepEqual(upd.params, [true, [5, 6]]); + 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: {} },