When a server template creates or changes roles, members online who hold them get their new permissions straight away, the same way editing a role in Settings pushes them; the role refresh helpers now live in roleRefresh.js so both paths share them (#5732)

This commit is contained in:
ancsemi committed 2026-10-05 18:44:29 -04:00
1 parent b85bdefeab
commit 4eac343574
7 files changed
+132 -89

No files matched your search

+5 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+5 -2
View File
@@ -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 };
+103
View File
@@ -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:<code> 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 };
};
+5 -81
View File
@@ -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:<code> 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;
+1 -1
View File
@@ -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);
+12 -2
View File
@@ -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');
});