mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-26 00:29:14 +00:00
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:
1 parent
b85bdefeab
commit
4eac343574
7 files changed
+132
-89
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user