diff --git a/src/routes/serverTemplate.js b/src/routes/serverTemplate.js index 7715b025..3833a8c3 100644 --- a/src/routes/serverTemplate.js +++ b/src/routes/serverTemplate.js @@ -89,6 +89,9 @@ module.exports = function registerServerTemplate(deps) { require('../automod').invalidate(); runtime.syncRoleGateMemberships(); runtime.broadcastChannelLists(); + // Holders of a role the template created or changed get their new + // permissions now, as when a role is edited in Settings. + runtime.refreshRoleHolders(Object.values(report.rolePermissions).map((x) => x.id)); late.io.except('bot-sockets').emit('roles-updated'); // Each changed setting goes out live and sets off, and is logged, just // as if it had been saved on the settings screen. @@ -111,8 +114,8 @@ module.exports = function registerServerTemplate(deps) { }, }); // One entry per role the template created or changed, with what it may do. - for (const [name, role] of Object.entries(report.rolePermissions)) { - runtime.logAudit({ actor: user, action: report.created.roles.includes(name) ? 'role_create' : 'role_update', target_type: 'role', target_name: name, details: { ...role, via: 'server template' } }); + for (const [name, { id, ...role }] of Object.entries(report.rolePermissions)) { + runtime.logAudit({ actor: user, action: report.created.roles.includes(name) ? 'role_create' : 'role_update', target_type: 'role', target_id: id, target_name: name, details: { ...role, via: 'server template' } }); } } catch (err) { console.error('Template follow-up failed:', err.message); } res.json(report); diff --git a/src/serverTemplate.js b/src/serverTemplate.js index 6188e0c3..3eadf8bd 100644 --- a/src/serverTemplate.js +++ b/src/serverTemplate.js @@ -517,7 +517,7 @@ function applyTemplate(db, tpl, opts = {}) { const id = found || db.prepare('INSERT INTO roles (name, level, scope, color, auto_assign, link_channel_access, icon, max_upload_mb) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(role.name, ...vals).lastInsertRowid; if (found) db.prepare('UPDATE roles SET name = ?, level = ?, scope = ?, color = ?, auto_assign = ?, link_channel_access = ?, icon = ?, max_upload_mb = ? WHERE id = ?').run(role.name, ...vals, found); setPerms(id, role); - r.rolePermissions[role.name] = { level: role.level, permissions: role.level > 0 ? role.permissions : [], denied: role.level > 0 ? role.denied : [] }; + r.rolePermissions[role.name] = { id, level: role.level, permissions: role.level > 0 ? role.permissions : [], denied: role.level > 0 ? role.denied : [] }; roleId.set(role.ref, id); (found ? r.updated : r.created).roles.push(role.name); if (auto) autoRole = id; diff --git a/src/socketHandlers/index.js b/src/socketHandlers/index.js index 4cc74800..a961a030 100644 --- a/src/socketHandlers/index.js +++ b/src/socketHandlers/index.js @@ -55,6 +55,7 @@ const registerModeration = require('./moderation'); const registerRoles = require('./roles'); const registerAdmin = require('./admin'); const { createSettingEffects } = require('./settingEffects'); +const createRoleRefresh = require('./roleRefresh'); const registerFerry = require('./ferry'); const registerGroupE2E = require('./groupE2E'); const registerTags = require('./tags'); @@ -180,6 +181,8 @@ function setupSocketHandlers(io, db, opts = {}) { // What a saved server setting sets off: live updates and follow-ups. const settingEffects = createSettingEffects({ io, automod, channelUsers, emitOnlineUsers, onReferrerPolicyChange, logAudit }); + // Live pushes to people whose roles or role permissions changed. + const roleRefresh = createRoleRefresh({ io, db, state, userHasPermission, getUserEffectiveLevel, getUserPermissions, getUserGlobalPermissions, getUserRoles, emitOnlineUsers, getEnrichedChannels }); // ── Voice relay (Large Server Setup) ───────────────────── // Off unless the admin turns it on. When its worker process goes away, the @@ -2666,7 +2669,7 @@ function setupSocketHandlers(io, db, opts = {}) { // Idle-online oversight (flag accounts sitting connected + green + silent) getIdleOnlineUsers, onReferrerPolicyChange, - settingEffects, + settingEffects, roleRefresh, // Per-member upload storage totals (#5521) getUploadUsage, botAudioManager, // Ban-filtered channel roster used by @mention autocomplete @@ -2821,7 +2824,7 @@ function setupSocketHandlers(io, db, opts = {}) { // Handed back so server.js can mount the account-linking HTTP routes against // the same engine instance the socket layer is using. - return { activity, state, userHasPermission, getUserEffectiveLevel, rotatePrivateCodesAfterRemoval, broadcastChannelLists, syncRoleGateMemberships, settingEffects, logAudit }; + return { activity, state, userHasPermission, getUserEffectiveLevel, rotatePrivateCodesAfterRemoval, broadcastChannelLists, syncRoleGateMemberships, settingEffects, logAudit, refreshRoleHolders: roleRefresh.refreshRoleHolders }; } module.exports = { setupSocketHandlers, sanitizeText, sanitizeSoundName, sanitizeBorderTransform, toReplyContext }; diff --git a/src/socketHandlers/roleRefresh.js b/src/socketHandlers/roleRefresh.js new file mode 100644 index 00000000..b9e0cbf4 --- /dev/null +++ b/src/socketHandlers/roleRefresh.js @@ -0,0 +1,103 @@ +'use strict'; + +// Pushing changed roles and permissions to the people who hold them, live, +// so nobody has to reconnect to see what they may now do. Made once by +// setupSocketHandlers and shared by the role handlers (roles.js) and server +// template imports. + +module.exports = function createRoleRefresh(ctx) { + const { + io, db, state, userHasPermission, getUserEffectiveLevel, + getUserPermissions, getUserGlobalPermissions, getUserRoles, + emitOnlineUsers, getEnrichedChannels + } = ctx; + + // ── Helper: live-refresh for 'view_all_channels' ──────── + // A role that grants view_all_channels changes what its holder can SEE, so + // push them a rebuilt channel list (joining the new rooms) the same way + // applyRoleChannelAccess does, instead of it only appearing on next login. + function roleGrantsSeeAll(roleId) { + return !!db.prepare( + "SELECT 1 FROM role_permissions WHERE role_id = ? AND permission = 'view_all_channels' AND allowed = 1" + ).get(roleId); + } + function pushChannelList(userId) { + for (const [, s] of io.sockets.sockets) { + if (s.user && s.user.id === userId) { + s.emit('channels-list', getEnrichedChannels(userId, s.user.isAdmin, (room) => s.join(room))); + } + } + } + + // ── Helper: undo view_all_channels auto-joins ─────────── + // Granting the permission writes a real membership row for every channel, so + // losing it has to take those rows back. Without this, demoting a mod leaves + // them sitting in every private channel on the server, which is the opposite + // of what demoting someone means. No-ops when they still hold the permission + // through some other role, and never touches memberships they got another + // way. (#5512) + function syncSeeAllMemberships(userId) { + const row = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId); + if (row && row.is_admin) return; + if (userHasPermission(userId, 'view_all_channels')) return; + + const losing = db.prepare(` + SELECT c.code FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id + WHERE cm.user_id = ? AND cm.auto_all_channels = 1 + `).all(userId); + if (!losing.length) return; + + db.prepare('DELETE FROM channel_members WHERE user_id = ? AND auto_all_channels = 1').run(userId); + + // Leaving the rooms matters as much as the rows do: a socket still in + // channel: keeps receiving live messages from a channel the user can + // no longer open. + for (const [, s] of io.sockets.sockets) { + if (s.user && s.user.id === userId) { + losing.forEach(ch => s.leave(`channel:${ch.code}`)); + s.emit('channels-list', getEnrichedChannels(userId, s.user.isAdmin, (room) => s.join(room))); + } + } + } + + // ── Notify helper: push one user's recomputed role state to their sockets ── + // Recomputes from the DB, so it's correct whether the change was to the + // user's role ASSIGNMENTS (assign/revoke/promote) or to the PERMISSIONS of a + // role they already hold (update-role / reset-roles-to-default). + function pushUserRoleState(userId) { + for (const [, s] of io.sockets.sockets) { + if (s.user && s.user.id === userId) { + s.user.roles = getUserRoles(userId); + s.user.effectiveLevel = getUserEffectiveLevel(userId); + s.emit('roles-updated', { + roles: s.user.roles, + effectiveLevel: s.user.effectiveLevel, + permissions: getUserPermissions(userId), + globalPermissions: getUserGlobalPermissions(userId) + }); + } + } + } + + // A role's permissions or level changed. Everyone online gets fresh member + // lists (badges and ordering), and each holder of the roles gets their own + // recomputed permission set: the payload-less roles-updated broadcast only + // nudges open Role Management screens, so without this a moderator granted + // e.g. ban_ip kept their old permissions until they reconnected. Used by + // update-role and by server template imports. + function refreshRoleHolders(roleIds) { + for (const [code] of state.channelUsers) { emitOnlineUsers(code); } + for (const roleId of roleIds) { + const affected = db.prepare('SELECT DISTINCT user_id FROM user_roles WHERE role_id = ?').all(roleId); + for (const row of affected) pushUserRoleState(row.user_id); + // Granting view_all_channels grows every holder's visible channel set, + // so their lists are refreshed live, as assign-role does; losing it + // takes back the memberships it added. + if (roleGrantsSeeAll(roleId)) for (const row of affected) pushChannelList(row.user_id); + else for (const row of affected) syncSeeAllMemberships(row.user_id); + } + } + + return { roleGrantsSeeAll, pushChannelList, syncSeeAllMemberships, pushUserRoleState, refreshRoleHolders }; +}; diff --git a/src/socketHandlers/roles.js b/src/socketHandlers/roles.js index 422be6ee..57e44a38 100644 --- a/src/socketHandlers/roles.js +++ b/src/socketHandlers/roles.js @@ -4,6 +4,7 @@ const bcrypt = require('bcryptjs'); const OTPAuth = require('otpauth'); const { isString, isInt, VALID_ROLE_PERMS, ROLE_MENU_EMOJI_RE, roleMenuContent } = require('./helpers'); const { seedDefaultRoles, createAdminRole, grantAdminRole } = require('../roleDefaults'); +const createRoleRefresh = require('./roleRefresh'); module.exports = function register(socket, ctx) { const { @@ -48,73 +49,9 @@ module.exports = function register(socket, ctx) { // Expose on ctx so other modules can use it if needed ctx.applyRoleChannelAccess = applyRoleChannelAccess; - // ── Helper: live-refresh for 'view_all_channels' ──────── - // A role that grants view_all_channels changes what its holder can SEE, so - // push them a rebuilt channel list (joining the new rooms) the same way - // applyRoleChannelAccess does, instead of it only appearing on next login. - function roleGrantsSeeAll(roleId) { - return !!db.prepare( - "SELECT 1 FROM role_permissions WHERE role_id = ? AND permission = 'view_all_channels' AND allowed = 1" - ).get(roleId); - } - function pushChannelList(userId) { - for (const [, s] of io.sockets.sockets) { - if (s.user && s.user.id === userId) { - s.emit('channels-list', getEnrichedChannels(userId, s.user.isAdmin, (room) => s.join(room))); - } - } - } - - // ── Helper: undo view_all_channels auto-joins ─────────── - // Granting the permission writes a real membership row for every channel, so - // losing it has to take those rows back. Without this, demoting a mod leaves - // them sitting in every private channel on the server, which is the opposite - // of what demoting someone means. No-ops when they still hold the permission - // through some other role, and never touches memberships they got another - // way. (#5512) - function syncSeeAllMemberships(userId) { - const row = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId); - if (row && row.is_admin) return; - if (userHasPermission(userId, 'view_all_channels')) return; - - const losing = db.prepare(` - SELECT c.code FROM channel_members cm - JOIN channels c ON c.id = cm.channel_id - WHERE cm.user_id = ? AND cm.auto_all_channels = 1 - `).all(userId); - if (!losing.length) return; - - db.prepare('DELETE FROM channel_members WHERE user_id = ? AND auto_all_channels = 1').run(userId); - - // Leaving the rooms matters as much as the rows do: a socket still in - // channel: keeps receiving live messages from a channel the user can - // no longer open. - for (const [, s] of io.sockets.sockets) { - if (s.user && s.user.id === userId) { - losing.forEach(ch => s.leave(`channel:${ch.code}`)); - s.emit('channels-list', getEnrichedChannels(userId, s.user.isAdmin, (room) => s.join(room))); - } - } - } - - // ── Notify helper: push one user's recomputed role state to their sockets ── - // Recomputes from the DB, so it's correct whether the change was to the - // user's role ASSIGNMENTS (assign/revoke/promote) or to the PERMISSIONS of a - // role they already hold (update-role / reset-roles-to-default). - function pushUserRoleState(userId) { - for (const [, s] of io.sockets.sockets) { - if (s.user && s.user.id === userId) { - s.user.roles = getUserRoles(userId); - s.user.effectiveLevel = getUserEffectiveLevel(userId); - s.emit('roles-updated', { - roles: s.user.roles, - effectiveLevel: s.user.effectiveLevel, - permissions: getUserPermissions(userId), - globalPermissions: getUserGlobalPermissions(userId) - }); - } - } - } + // Live refresh for people whose roles changed (roleRefresh.js). Made once + // in index.js; a handler set up on its own (tests) makes its own. + const { roleGrantsSeeAll, pushChannelList, syncSeeAllMemberships, pushUserRoleState, refreshRoleHolders } = ctx.roleRefresh || createRoleRefresh(ctx); // Same, plus a member-list refresh everywhere (role badges/ordering change). function refreshUserRoles(userId) { @@ -663,20 +600,7 @@ module.exports = function register(socket, ctx) { perms.forEach(p => { if (!pm[p.role_id]) pm[p.role_id] = []; pm[p.role_id].push(p.permission); }); freshRoles.forEach(r => { r.permissions = pm[r.id] || []; }); - for (const [code] of channelUsers) { emitOnlineUsers(code); } - - // Editing a role's PERMISSIONS changes what every member of that role can - // do, so each of them needs their own recomputed permission set pushed. - // The broadcast below carries no payload — it's only a "the server's role - // list changed" nudge for open Role Management modals — so without this - // loop a moderator granted e.g. ban_ip kept their stale permission set - // (and the hidden IP-ban option) until they reconnected. - const affected = db.prepare('SELECT DISTINCT user_id FROM user_roles WHERE role_id = ?').all(roleId); - for (const row of affected) pushUserRoleState(row.user_id); - // If the edit granted view_all_channels, every holder's visible channel - // set just grew — refresh their lists live, like assign-role does. - if (roleGrantsSeeAll(roleId)) for (const row of affected) pushChannelList(row.user_id); - else for (const row of affected) syncSeeAllMemberships(row.user_id); + refreshRoleHolders([roleId]); const nameChanged = data.name !== undefined && isString(data.name, 1, 30) && data.name.trim() !== role.name; const levelChanged = data.level !== undefined && newLevel !== role.level; diff --git a/test/serverTemplate.test.js b/test/serverTemplate.test.js index c73ee3fa..702a242f 100644 --- a/test/serverTemplate.test.js +++ b/test/serverTemplate.test.js @@ -67,7 +67,7 @@ const seed = () => { const seeded = seed(); const exported = () => tpl.exportTemplate(source, { uploadsDir: src.uploads, themesDir: src.themes, excludeChannels: ['secret-plans'], havenVersion: 'test' }); const strip = (t) => { const o = JSON.parse(JSON.stringify(t)); delete o.exportedAt; const names = Object.fromEntries(Object.entries(o.assets).map(([n, a]) => [n, a.sha256])); const fix = (v) => names[v] || v; o.server.icon = fix(o.server.icon); o.roles = o.roles.map((r) => ({ ...r, icon: fix(r.icon) })); o.webhooks = o.webhooks.map((w) => ({ ...w, avatar: fix(w.avatar) })); o.posts = o.posts.map((p) => ({ ...p, avatar: fix(p.avatar), content: p.content.replace(/\{\{asset:([^}]+)\}\}/g, (_, n) => fix(n)) })); o.emojis = o.emojis.map((e) => ({ ...e, asset: fix(e.asset) })); o.assets = Object.values(names).sort(); return o; }; -test.after(() => { try { source.close(); } catch { /* already closed */ } fs.rmSync(ROOT, { recursive: true, force: true }); }); +test.after(() => { try { source.close(); } catch { /* already closed */ } fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); test('export keeps the layout and leaves out people, messages and secrets', () => { const { template } = exported(); const json = JSON.stringify(template); diff --git a/test/serverTemplateApi.integration.test.js b/test/serverTemplateApi.integration.test.js index 08b32003..bea48af5 100644 --- a/test/serverTemplateApi.integration.test.js +++ b/test/serverTemplateApi.integration.test.js @@ -8,7 +8,7 @@ const path = require('node:path'); const { spawn } = require('node:child_process'); const { io } = require('socket.io-client'); const DATA = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-template-api-')); -let server, base; +let server, base, sock; const freePort = () => new Promise((resolve, reject) => { const s = net.createServer(); s.once('error', reject); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); const json = async (res) => ({ status: res.status, body: await res.json().catch(() => null) }); const post = (p, token, body) => fetch(base + p, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) }).then(json); @@ -35,6 +35,8 @@ test.before(async () => { // Windows keeps the database locked until the server process has really // exited, so wait for it before removing the data folder. test.after(async () => { + // A failed assertion leaves the socket open, which would keep the run alive. + sock?.close(); if (server && server.exitCode === null) { const exited = new Promise((r) => server.once('exit', r)); server.kill(); @@ -69,7 +71,7 @@ test('server templates over HTTP: admin only, preview, apply, live update', asyn assert.equal(plan.status, 200); assert.deepEqual(plan.body.created.channels, ['lobby', 'reading-now', 'hosts']); assert.equal(plan.body.dryRun, true); - const sock = io(base, { auth: { token: member.token }, transports: ['websocket'], forceNew: true }); + sock = io(base, { auth: { token: member.token }, transports: ['websocket'], forceNew: true }); await new Promise((r, j) => { sock.on('connect', r); sock.on('connect_error', j); }); const listed = new Promise((r) => sock.on('channels-list', (list) => { if (list.some((c) => c.name === 'lobby')) r(list); })); const renamed = new Promise((r) => sock.on('server-setting-changed', (s) => { if (s.key === 'server_name_effective' && s.value === 'Book Club') r(true); })); @@ -90,6 +92,14 @@ test('server templates over HTTP: admin only, preview, apply, live update', asyn assert.ok(audit.some((a) => a.action === 'server_setting_update' && a.target_name === 'server_name'), 'each changed setting is logged'); const host = audit.find((a) => a.action === 'role_create' && a.target_name === 'Host'); assert.deepEqual(JSON.parse(host.details).permissions, ['pin_message', 'set_channel_topic'], 'the new role is logged with its permissions'); + // Replacing a role a member holds pushes them their new permissions at once. + const trimmed = new Promise((r) => sock.on('roles-updated', (d) => { if (d && Array.isArray(d.permissions) && !d.permissions.includes('upload_files')) r(d.permissions); })); + const up2 = await upload(admin.token, { format: 'haven-server-template', version: 1, meta: { name: 'Quiet' }, roles: [{ ref: 'member', name: 'Member', level: 1, permissions: ['use_voice', 'view_history'] }] }); + assert.equal(up2.status, 200, JSON.stringify(up2.body)); + const applied2 = await post('/api/admin/template/apply', admin.token, { id: up2.body.id, mode: 'replace' }); + assert.deepEqual(applied2.body.updated.roles, ['Member'], JSON.stringify(applied2.body)); + const pushed = await Promise.race([trimmed, new Promise((r) => setTimeout(() => r(null), 5000))]); + assert.ok(pushed && pushed.includes('use_voice'), 'the member gets their new permissions without reconnecting'); sock.close(); assert.equal((await post('/api/admin/template/apply', admin.token, { id: up.body.id })).status, 404, 'an upload is used once'); });