From d7271ec3594ac5f9672b01103481bd8d3f4421cb Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 21 Sep 2026 12:42:28 +0200 Subject: [PATCH 1/2] Write the rules cache sentinel as an escape so git can diff the file services/channelPermissions.ts held a raw NUL byte in UNREADABLE, so git and GitHub treated the whole file as binary and showed no diff for it. The escape is the same string. Co-Authored-By: Claude Opus 5 --- src/services/channelPermissions.ts | Bin 7084 -> 7089 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/services/channelPermissions.ts b/src/services/channelPermissions.ts index 8b6c11bc4023c42e9774d583e472ab0a2cae8426..33e46c388a52651f2b1c36aa4e5b10f08e1bc786 100644 GIT binary patch delta 19 ZcmZ2uzR`Sx5ffWXsR0mdHfE}n1OPzR1;GFS delta 14 VcmdmJzQ%lm5fdZBW(%fDNdP4s1TX*q From 5aa7f296663c77a9d39e6ff39200c38f3b8e8932 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 21 Sep 2026 12:42:28 +0200 Subject: [PATCH 2/2] Folders carry permissions their channels follow (GRYT-1306) A folder has a permission scope now, the same as a channel: Everyone, a template, or rules of its own, set with server:folders:scope:set. A channel in a folder follows it until somebody picks the channel's own, Everyone included, and server:channels:scope:follow hands it back. sidebar_items.permission_scope_id holds the folder's scope, and channels.follows_folder says whether a channel with none of its own takes it. The migration adds both and writes nothing else. A scope of its own beats the flag, so every channel answers as it did before. Folders start with no scope, so a channel with none still gets the server-wide answer. resolveChannelScopes is the one place that works out which scope decides a channel. The permission cache is built from it, so reading, sending, speaking, joining voice, typing, the member list, webhooks, reports and file access all use the folder's scope where a channel follows one. server:details and server:sidebar:list leave out a folder, name and all, for anyone who can't see a channel in it. manage_channels still gets every folder. server:sidebar:list used to hand any member every row, hidden channels' ids included, and is filtered the same way now. A channel that follows a folder with a scope keeps that scope as its own when it ends up in no folder: dragged to the top level, its row deleted, or the folder deleted. A folder's own rules are copied. Moving a following channel into a folder with a different scope needs manage_channels as well as manage_sidebar, since that changes who can see it. voice:room:request refuses a room whose scope denies join_voice, which only the client's canJoin knew about before. server:channels:upsert takes parentItemId, so a new channel's first row is already in its folder. A sidebar write removes other rows for the same channel in the same step instead of a few awaits later. Deleting a template puts the channels and folders on it back to Everyone. folderPermissions.test.ts drives the handlers as a member, an admin and somebody with only manage_sidebar, and folderScopeMigration.test.ts upgrades a database from the previous schema. Co-Authored-By: Claude Opus 5 --- src/db/interfaces.ts | 9 +- src/db/sqlite/channelScopes.ts | 166 ++++++- src/db/sqlite/channels.ts | 83 +++- src/db/sqlite/connection.ts | 20 +- src/db/sqlite/folderScopeMigration.test.ts | 91 ++++ src/services/channelPermissions.ts | 49 +- src/socket/handlers/adminChannels.ts | 384 +++++++++++---- .../handlers/channelVisibilityLeaks.test.ts | 8 + src/socket/handlers/folderPermissions.test.ts | 457 ++++++++++++++++++ src/socket/handlers/permissionGates.test.ts | 3 + src/socket/handlers/voice.ts | 11 + src/socket/utils/server.ts | 27 +- 12 files changed, 1166 insertions(+), 142 deletions(-) create mode 100644 src/db/sqlite/folderScopeMigration.test.ts create mode 100644 src/socket/handlers/folderPermissions.test.ts diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 704b5fbd..3f14bff8 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -349,9 +349,12 @@ export interface ServerChannelRecord { /** Null means anybody holding send_messages, which is every channel unless an operator narrows it. */ post_min_rank: number | null; - /** Null means the channel has no opinion. Never resolve a permission by - reading this; `channelPermissions.ts` is the one answer. */ + /** Null means no scope of its own. Never resolve a permission by reading + this; `resolveChannelScopes` is the one answer, folder included. */ permission_scope_id: string | null; + /** Whether a channel with no scope of its own takes its folder's. False once + somebody picks its permissions, which only shows for Everyone. */ + follows_folder: boolean; /** Migrated into a scope on upgrade and unread afterwards. Kept so a rollback still enforces the gate it had rather than losing it silently. */ view_min_rank: number | null; @@ -403,6 +406,8 @@ export interface ServerSidebarItemRecord { /** Only a channel may have one: the sidebar has one indent step, and a divider inside a folder divides nothing. */ parent_item_id: string | null; + /** A folder's scope, taken by every channel in it that follows it. */ + permission_scope_id: string | null; created_at: Date; updated_at: Date; } diff --git a/src/db/sqlite/channelScopes.ts b/src/db/sqlite/channelScopes.ts index 8668a14b..759d8b1e 100644 --- a/src/db/sqlite/channelScopes.ts +++ b/src/db/sqlite/channelScopes.ts @@ -1,9 +1,114 @@ import { randomUUID } from "crypto"; import { CHANNEL_PERMISSIONS, isChannelPermission, type ChannelPermission } from "../../constants/permissions"; -import type { ChannelPermissionRuleRecord, ChannelPermissionScopeRecord, RuleEffect } from "../interfaces"; +import type { + ChannelPermissionRuleRecord, + ChannelPermissionScopeRecord, + RuleEffect, + ServerChannelRecord, + ServerSidebarItemRecord, +} from "../interfaces"; import { fromIso, getSqliteDb, intToBool, toIso } from "./connection"; +export interface ResolvedChannelScope { + /** What decides this channel. Null is every role's server-wide answer. */ + scopeId: string | null; + /** The folder it sits in, or null at the top level. */ + folderId: string | null; + followsFolder: boolean; +} + +/** + * The one place a channel's scope is worked out: its folder's while it follows + * one, otherwise its own. `items` in listing order, since a channel's first row wins. + */ +export function resolveChannelScopes( + channels: readonly Pick[], + items: readonly Pick[], +): Map { + const folderScopes = new Map(); + for (const it of items) if (it.kind === "folder") folderScopes.set(it.item_id, it.permission_scope_id ?? null); + + const folderOf = new Map(); + for (const it of items) { + if (it.kind !== "channel" || !it.channel_id || folderOf.has(it.channel_id)) continue; + const parent = it.parent_item_id ?? null; + folderOf.set(it.channel_id, parent && folderScopes.has(parent) ? parent : null); + } + + const resolved = new Map(); + for (const c of channels) { + const folderId = folderOf.get(c.channel_id) ?? null; + // A scope of its own wins, so an older build writing one is never overridden. + const followsFolder = folderId !== null && !c.permission_scope_id && c.follows_folder; + resolved.set(c.channel_id, { + scopeId: followsFolder ? folderScopes.get(folderId as string) ?? null : c.permission_scope_id ?? null, + folderId, + followsFolder, + }); + } + return resolved; +} + +/** A private scope belongs to one channel or one folder, so it goes when the + last thing using it does. A template stays. */ +export function dropPermissionScopeIfUnused(scopeId: string): void { + const db = getSqliteDb(); + const scope = db + .prepare(`SELECT is_template FROM channel_permission_scopes WHERE scope_id = ?`) + .get(scopeId) as { is_template: number } | undefined; + if (!scope || scope.is_template) return; + + const used = db + .prepare( + `SELECT (SELECT COUNT(*) FROM channels WHERE permission_scope_id = ?) + + (SELECT COUNT(*) FROM sidebar_items WHERE permission_scope_id = ?) AS n`, + ) + .get(scopeId, scopeId) as { n: number }; + if (used.n > 0) return; + + db.prepare(`DELETE FROM channel_permission_rules WHERE scope_id = ?`).run(scopeId); + db.prepare(`DELETE FROM channel_permission_scopes WHERE scope_id = ?`).run(scopeId); +} + +/** + * Leaving a folder never opens a channel: one that followed a folder's scope and + * sits in none now keeps it as its own. Runs inside the caller's transaction. + */ +export function keepScopesOfChannelsLeavingFolders( + before: Map, + after: Map, +): void { + const db = getSqliteDb(); + const now = toIso(new Date()); + + for (const [channelId, was] of before) { + const is = after.get(channelId); + if (!was.followsFolder || !was.scopeId || !is || is.folderId) continue; + + const scope = db + .prepare(`SELECT is_template FROM channel_permission_scopes WHERE scope_id = ?`) + .get(was.scopeId) as { is_template: number } | undefined; + if (!scope) continue; + + // A folder's private scope is copied, not shared: it dies with the folder. + let own = was.scopeId; + if (!scope.is_template) { + own = `scope_${randomUUID().slice(0, 12)}`; + db.prepare( + `INSERT INTO channel_permission_scopes (scope_id, name, is_template, is_system, created_at, updated_at) + VALUES (?, NULL, 0, 0, ?, ?)`, + ).run(own, now, now); + db.prepare( + `INSERT INTO channel_permission_rules (scope_id, role_id, permission, effect, created_at) + SELECT ?, role_id, permission, effect, ? FROM channel_permission_rules WHERE scope_id = ?`, + ).run(own, now, was.scopeId); + } + db.prepare(`UPDATE channels SET permission_scope_id = ?, follows_folder = 0, updated_at = ? WHERE channel_id = ?`) + .run(own, now, channelId); + } +} + function rowToScope(r: Record): ChannelPermissionScopeRecord { return { scope_id: r.scope_id as string, @@ -128,9 +233,13 @@ export async function replacePermissionRules( } } -/** Deleting the private scope it owned is part of this, since that belongs to - one channel and would be left unreachable. A template is left alone. */ -export async function setChannelPermissionScope(channelId: string, scopeId: string | null): Promise { +/** Any choice made here is the channel's own, Everyone included, unless + `followFolder` hands it back to its folder. The private scope it owned goes. */ +export async function setChannelPermissionScope( + channelId: string, + scopeId: string | null, + { followFolder = false }: { followFolder?: boolean } = {}, +): Promise { const db = getSqliteDb(); const now = toIso(new Date()); @@ -140,22 +249,35 @@ export async function setChannelPermissionScope(channelId: string, scopeId: stri .prepare(`SELECT permission_scope_id FROM channels WHERE channel_id = ?`) .get(channelId) as { permission_scope_id: string | null } | undefined; - db.prepare(`UPDATE channels SET permission_scope_id = ?, updated_at = ? WHERE channel_id = ?`) - .run(scopeId, now, channelId); + db.prepare(`UPDATE channels SET permission_scope_id = ?, follows_folder = ?, updated_at = ? WHERE channel_id = ?`) + .run(scopeId, followFolder && !scopeId ? 1 : 0, now, channelId); const old = previous?.permission_scope_id; - if (old && old !== scopeId) { - const stillUsed = db - .prepare(`SELECT COUNT(*) AS n FROM channels WHERE permission_scope_id = ?`) - .get(old) as { n: number }; - const scope = db - .prepare(`SELECT is_template FROM channel_permission_scopes WHERE scope_id = ?`) - .get(old) as { is_template: number } | undefined; - if (scope && !scope.is_template && stillUsed.n === 0) { - db.prepare(`DELETE FROM channel_permission_rules WHERE scope_id = ?`).run(old); - db.prepare(`DELETE FROM channel_permission_scopes WHERE scope_id = ?`).run(old); - } - } + if (old && old !== scopeId) dropPermissionScopeIfUnused(old); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } +} + +/** The folder's half of `setChannelPermissionScope`. Its channels read it + through `resolveChannelScopes`, so nothing is written to them. */ +export async function setFolderPermissionScope(folderItemId: string, scopeId: string | null): Promise { + const db = getSqliteDb(); + const now = toIso(new Date()); + + db.exec("BEGIN"); + try { + const previous = db + .prepare(`SELECT permission_scope_id FROM sidebar_items WHERE item_id = ? AND kind = 'folder'`) + .get(folderItemId) as { permission_scope_id: string | null } | undefined; + + db.prepare(`UPDATE sidebar_items SET permission_scope_id = ?, updated_at = ? WHERE item_id = ? AND kind = 'folder'`) + .run(scopeId, now, folderItemId); + + const old = previous?.permission_scope_id; + if (old && old !== scopeId) dropPermissionScopeIfUnused(old); db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -163,14 +285,16 @@ export async function setChannelPermissionScope(channelId: string, scopeId: stri } } -/** Not left dangling: a channel pointing at a gone scope resolves to inheriting - only by accident, and the settings dropdown shows nothing selected. */ +/** Channels and folders on it go to Everyone rather than point at nothing, so + what the dropdown shows is what applies. */ export async function deletePermissionTemplate(scopeId: string): Promise { const db = getSqliteDb(); const now = toIso(new Date()); db.exec("BEGIN"); try { - db.prepare(`UPDATE channels SET permission_scope_id = NULL, updated_at = ? WHERE permission_scope_id = ?`) + db.prepare(`UPDATE channels SET permission_scope_id = NULL, follows_folder = 0, updated_at = ? WHERE permission_scope_id = ?`) + .run(now, scopeId); + db.prepare(`UPDATE sidebar_items SET permission_scope_id = NULL, updated_at = ? WHERE permission_scope_id = ?`) .run(now, scopeId); db.prepare(`DELETE FROM channel_permission_rules WHERE scope_id = ?`).run(scopeId); db.prepare(`DELETE FROM channel_permission_scopes WHERE scope_id = ? AND is_system = 0`).run(scopeId); diff --git a/src/db/sqlite/channels.ts b/src/db/sqlite/channels.ts index 344b47a3..b7e1d571 100644 --- a/src/db/sqlite/channels.ts +++ b/src/db/sqlite/channels.ts @@ -1,4 +1,10 @@ import type { ChannelNotificationLevel, ForumTag, ServerChannelRecord, ServerSidebarItemKind, ServerSidebarItemRecord } from "../interfaces"; +import { + dropPermissionScopeIfUnused, + keepScopesOfChannelsLeavingFolders, + resolveChannelScopes, + type ResolvedChannelScope, +} from "./channelScopes"; import { fromIso, getSqliteDb, intToBool, toIso } from "./connection"; function normalizeChannelType(t: unknown): "text" | "voice" { @@ -69,6 +75,7 @@ function rowToChannel(r: Record): ServerChannelRecord { post_min_rank: r.post_min_rank != null ? Number(r.post_min_rank) : null, view_min_rank: r.view_min_rank != null ? Number(r.view_min_rank) : null, permission_scope_id: (r.permission_scope_id as string) ?? null, + follows_folder: intToBool(r.follows_folder as number), created_at: fromIso(r.created_at as string), updated_at: fromIso(r.updated_at as string), }; @@ -83,6 +90,7 @@ function rowToSidebarItem(r: Record): ServerSidebarItemRecord { spacer_height: r.spacer_height != null ? Number(r.spacer_height) : null, label: (r.label as string) ?? null, parent_item_id: (r.parent_item_id as string) ?? null, + permission_scope_id: (r.permission_scope_id as string) ?? null, created_at: fromIso(r.created_at as string), updated_at: fromIso(r.updated_at as string), }; @@ -215,10 +223,39 @@ export async function upsertServerSidebarItem(item: { : null; const parentItemId = resolveParentFolder(db, itemId, kind, item.parentItemId); - db.prepare( - `INSERT INTO sidebar_items (item_id, kind, position, channel_id, spacer_height, label, parent_item_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(item_id) DO UPDATE SET kind=?, position=?, channel_id=?, spacer_height=?, label=?, parent_item_id=?, updated_at=?` - ).run(itemId, kind, position, channelId, spacerHeight, label, parentItemId, now, now, kind, position, channelId, spacerHeight, label, parentItemId, now); + const upsert = () => { + db.prepare( + `INSERT INTO sidebar_items (item_id, kind, position, channel_id, spacer_height, label, parent_item_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(item_id) DO UPDATE SET kind=?, position=?, channel_id=?, spacer_height=?, label=?, parent_item_id=?, updated_at=?` + ).run(itemId, kind, position, channelId, spacerHeight, label, parentItemId, now, now, kind, position, channelId, spacerHeight, label, parentItemId, now); + // One row per channel, and the one written wins. In the same step, so nothing + // reads the channel drawn twice in between. + if (kind === "channel" && channelId) { + db.prepare(`DELETE FROM sidebar_items WHERE kind = 'channel' AND channel_id = ? AND item_id <> ?`).run(channelId, itemId); + } + }; + + // Only an existing row changing shape, or a second row going, can take a channel out of a folder. + const stored = db + .prepare(`SELECT kind, channel_id, parent_item_id FROM sidebar_items WHERE item_id = ?`) + .get(itemId) as { kind: string; channel_id: string | null; parent_item_id: string | null } | undefined; + const another = kind === "channel" && channelId + ? db.prepare(`SELECT 1 FROM sidebar_items WHERE kind = 'channel' AND channel_id = ? AND item_id <> ?`).get(channelId, itemId) + : undefined; + const reshapes = !!another || (!!stored && ( + stored.kind !== kind || (stored.channel_id ?? null) !== channelId || (stored.parent_item_id ?? null) !== parentItemId + )); + if (reshapes) writeKeepingFolderScopes(upsert); + else upsert(); +} + +/** A new channel's first row, unless one is already down. Checked and written in + one step: the desktop sends its own row right behind the channel. */ +export async function addChannelRowIfMissing(channelId: string, parentItemId: string | null): Promise { + const db = getSqliteDb(); + if (db.prepare(`SELECT 1 FROM sidebar_items WHERE kind = 'channel' AND channel_id = ?`).get(channelId)) return; + const { end } = db.prepare(`SELECT COALESCE(MAX(position), 0) + 10 AS end FROM sidebar_items`).get() as { end: number }; + await upsertServerSidebarItem({ itemId: `sb_ch_${channelId.slice(0, 54)}`, kind: "channel", channelId, position: end, parentItemId }); } /** A channel's sidebar entry is the only thing that puts it on screen, so @@ -227,8 +264,42 @@ export async function deleteServerSidebarItem(itemId: string): Promise { const db = getSqliteDb(); const norm = String(itemId || "").trim().slice(0, 64); if (!norm) return; - db.prepare(`UPDATE sidebar_items SET parent_item_id = NULL WHERE parent_item_id = ?`).run(norm); - db.prepare(`DELETE FROM sidebar_items WHERE item_id = ?`).run(norm); + const folder = db + .prepare(`SELECT permission_scope_id FROM sidebar_items WHERE item_id = ? AND kind = 'folder'`) + .get(norm) as { permission_scope_id: string | null } | undefined; + + writeKeepingFolderScopes(() => { + db.prepare(`UPDATE sidebar_items SET parent_item_id = NULL WHERE parent_item_id = ?`).run(norm); + db.prepare(`DELETE FROM sidebar_items WHERE item_id = ?`).run(norm); + }); + // Not before: its channels were copying from it until the write above. + if (folder?.permission_scope_id) dropPermissionScopeIfUnused(folder.permission_scope_id); +} + +/** Each channel's scope as it stands, read synchronously so it can sit inside + a transaction. */ +function scopesNow(): Map { + const db = getSqliteDb(); + const channels = (db.prepare(`SELECT * FROM channels`).all() as Record[]).map(rowToChannel); + const items = (db.prepare(`SELECT * FROM sidebar_items ORDER BY position ASC, item_id ASC`).all() as Record[]) + .map(rowToSidebarItem); + return resolveChannelScopes(channels, items); +} + +/** The write and the scopes it hands out commit together, so no reader sees a + channel out of its folder and open in between. */ +function writeKeepingFolderScopes(write: () => void): void { + const db = getSqliteDb(); + db.exec("BEGIN"); + try { + const before = scopesNow(); + write(); + keepScopesOfChannelsLeavingFolders(before, scopesNow()); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } } /** As {@link ensureDefaultChannels}: true when this call seeded something. */ diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 4d70178a..86d84682 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -245,10 +245,13 @@ function createSchema(d: DatabaseSync): void { view_min_rank INTEGER, -- Which permission scope decides what each role may do here. -- - -- NULL means the channel has no opinion: every role gets exactly what its - -- server-wide definition gives it. That is every channel until somebody - -- narrows one, so the common case stores nothing and costs nothing. + -- NULL means the channel has no scope of its own. It takes its folder's + -- while follows_folder is 1, and otherwise every role gets exactly what its + -- server-wide definition gives it. resolveChannelScopes decides which. permission_scope_id TEXT, + -- 0 once somebody picks the channel's permissions for it. Only Everyone + -- needs it, since a scope of its own wins whatever this says. + follows_folder INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -303,6 +306,8 @@ function createSchema(d: DatabaseSync): void { spacer_height INTEGER, label TEXT, parent_item_id TEXT, + -- A folder's scope, which its channels take unless they have their own. + permission_scope_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -593,6 +598,15 @@ function runMigrations(d: DatabaseSync): void { d.exec("ALTER TABLE sidebar_items ADD COLUMN parent_item_id TEXT"); } + // No backfill: every folder starts with no scope, so a channel following one + // reads through to the server-wide answer it had before. + if (!hasColumn(d, "sidebar_items", "permission_scope_id")) { + d.exec("ALTER TABLE sidebar_items ADD COLUMN permission_scope_id TEXT"); + } + if (!hasColumn(d, "channels", "follows_folder")) { + d.exec("ALTER TABLE channels ADD COLUMN follows_folder INTEGER NOT NULL DEFAULT 1"); + } + // Older databases predate both tables. CREATE TABLE IF NOT EXISTS above only // runs against a fresh file, so upgrading needs them here as well. d.exec(` diff --git a/src/db/sqlite/folderScopeMigration.test.ts b/src/db/sqlite/folderScopeMigration.test.ts new file mode 100644 index 00000000..3a80b1a3 --- /dev/null +++ b/src/db/sqlite/folderScopeMigration.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import { mayViewChannel, resetChannelPermissionCache } from "../../services/channelPermissions"; +import { listServerChannels, listServerSidebarItems, upsertServerChannel, upsertServerSidebarItem } from "./channels"; +import { createPermissionScope, replacePermissionRules, setFolderPermissionScope } from "./channelScopes"; +import { getSqliteDb, initSqlite } from "./connection"; +import { createRoleDefinition } from "./roleDefinitions"; +import { createServerConfigIfNotExists, setServerRole } from "./servers"; +import { upsertUser } from "./users"; + +/** A database from the build before folder permissions, upgraded in place: every + channel has to answer as it did, a scoped one inside a folder included. */ + +let dir: string; +let lowUser = ""; + +const SCOPED = "mig-scoped"; +const PLAIN = "mig-plain"; +const TOP = "mig-top"; +const FOLDER = "mig-folder"; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-foldermig-")); + process.env.DATA_DIR = dir; + await initSqlite(); + await createServerConfigIfNotExists(); + + await createRoleDefinition("mig-low", { name: "Low", rank: 10, permissions: ["read_messages", "send_messages"] }); + const low = await upsertUser("acct-mig-low", "Low"); + await setServerRole(low.server_user_id, "mig-low"); + lowUser = low.server_user_id; + + // Back to the previous release's shape, then filled the way it filled it. + const db = getSqliteDb(); + db.exec("ALTER TABLE channels DROP COLUMN follows_folder"); + db.exec("ALTER TABLE sidebar_items DROP COLUMN permission_scope_id"); + + await upsertServerChannel({ channelId: SCOPED, name: "Scoped", type: "text" }); + await upsertServerChannel({ channelId: PLAIN, name: "Plain", type: "text" }); + await upsertServerChannel({ channelId: TOP, name: "Top", type: "text" }); + await upsertServerSidebarItem({ itemId: FOLDER, kind: "folder", label: "Old folder", position: 10 }); + await upsertServerSidebarItem({ itemId: "sb-scoped", kind: "channel", channelId: SCOPED, position: 20, parentItemId: FOLDER }); + await upsertServerSidebarItem({ itemId: "sb-plain", kind: "channel", channelId: PLAIN, position: 30, parentItemId: FOLDER }); + await upsertServerSidebarItem({ itemId: "sb-top", kind: "channel", channelId: TOP, position: 40 }); + + const hidden = await createPermissionScope({ name: "Hidden from low", isTemplate: true }); + await replacePermissionRules(hidden, [{ roleId: "mig-low", permission: "read_messages", effect: "deny" }]); + db.prepare(`UPDATE channels SET permission_scope_id = ? WHERE channel_id = ?`).run(hidden, SCOPED); + + await initSqlite(); + resetChannelPermissionCache(); +}); + +after(() => { + delete process.env.DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("upgrading a server that has folders", () => { + it("adds both columns with nothing in the folder's", async () => { + const channels = await listServerChannels(); + assert.ok(channels.every((c) => c.follows_folder), "every channel starts out following"); + const folder = (await listServerSidebarItems()).find((i) => i.item_id === FOLDER); + assert.equal(folder?.permission_scope_id, null); + }); + + it("answers every channel as it did before", async () => { + assert.equal(await mayViewChannel(SCOPED, lowUser), false, "a scoped channel in a folder lost its scope"); + assert.equal(await mayViewChannel(PLAIN, lowUser), true); + assert.equal(await mayViewChannel(TOP, lowUser), true); + }); + + it("gives the folder's scope to the unscoped channel only, once it has one", async () => { + const staff = await createPermissionScope({ isTemplate: false }); + await replacePermissionRules(staff, [{ roleId: "mig-low", permission: "read_messages", effect: "deny" }]); + await setFolderPermissionScope(FOLDER, staff); + resetChannelPermissionCache(); + + assert.equal(await mayViewChannel(PLAIN, lowUser), false, "the unscoped channel should follow its folder"); + assert.equal(await mayViewChannel(TOP, lowUser), true, "a channel outside the folder is not in it"); + + const open = await createPermissionScope({ isTemplate: false }); + await setFolderPermissionScope(FOLDER, open); + resetChannelPermissionCache(); + assert.equal(await mayViewChannel(SCOPED, lowUser), false, "a scope of its own has to beat the folder's"); + }); +}); diff --git a/src/services/channelPermissions.ts b/src/services/channelPermissions.ts index 33e46c38..f39c10b3 100644 --- a/src/services/channelPermissions.ts +++ b/src/services/channelPermissions.ts @@ -1,6 +1,6 @@ import type { ChannelPermission } from "../constants/permissions"; -import type { ChannelPermissionRuleRecord } from "../db/interfaces"; -import { listAllPermissionRules, listServerChannels } from "../db"; +import type { ChannelPermissionRuleRecord, ServerSidebarItemRecord } from "../db/interfaces"; +import { listAllPermissionRules, listServerChannels, listServerSidebarItems, resolveChannelScopes } from "../db"; import { getEffectiveStanding } from "./permissions"; /** @@ -22,10 +22,15 @@ interface CachedRules { let rulesCache: CachedRules | null = null; async function readRules(): Promise { - const [byScope, channels] = await Promise.all([listAllPermissionRules(), listServerChannels()]); - const scopeByChannel = new Map( - channels.map((c) => [c.channel_id, c.permission_scope_id ?? null]), - ); + const [byScope, channels, items] = await Promise.all([ + listAllPermissionRules(), + listServerChannels(), + listServerSidebarItems(), + ]); + const scopeByChannel = new Map(); + for (const [channelId, resolved] of resolveChannelScopes(channels, items)) { + scopeByChannel.set(channelId, resolved.scopeId); + } rulesCache = { byScope, scopeByChannel, fetchedAt: Date.now() }; return rulesCache; } @@ -37,7 +42,7 @@ async function currentRules(): Promise { } /** After any write that could change an answer: editing a scope, repointing a - channel, deleting a template, deleting a role that rules name. */ + channel or a folder, moving a channel, deleting a template or a role. */ export function resetChannelPermissionCache(): void { rulesCache = null; } @@ -193,3 +198,33 @@ export async function scopedChannelIds(): Promise> { return new Set([UNREADABLE]); } } + +/** Hidden channels' rows go, then every folder with none of its rows left. A + manager keeps empty folders, having somewhere to put a channel. */ +export function visibleSidebarItems< + T extends Pick, +>(items: readonly T[], visible: ReadonlySet, keepEmptyFolders: boolean): T[] { + const kept = items.filter((it) => it.kind !== "channel" || !it.channel_id || visible.has(it.channel_id)); + if (keepEmptyFolders) return kept; + + const filled = new Set(); + for (const it of kept) { + if (it.kind === "channel" && it.channel_id && it.parent_item_id) filled.add(it.parent_item_id); + } + return kept.filter((it) => it.kind !== "folder" || filled.has(it.item_id)); +} + +/** `manage_channels` lists every channel anyway, so its empty folders name + nothing new. Fails shut, to hiding them. */ +export async function keepsEmptyFolders( + serverUserId: string | null | undefined, + grytUserId?: string, +): Promise { + if (!serverUserId || serverUserId.startsWith("temp_")) return false; + try { + const standing = await getEffectiveStanding(serverUserId, grytUserId); + return standing.isOwner || standing.permissions.has("manage_channels"); + } catch { + return false; + } +} diff --git a/src/socket/handlers/adminChannels.ts b/src/socket/handlers/adminChannels.ts index bd3a09a8..424e0ba9 100644 --- a/src/socket/handlers/adminChannels.ts +++ b/src/socket/handlers/adminChannels.ts @@ -3,7 +3,7 @@ import { forgetStashedVoiceState } from "../utils/voiceStash"; import consola from "consola"; import { randomUUID } from "crypto"; import type { HandlerContext, EventHandlerMap } from "./types"; -import { requireAuth } from "../middleware/auth"; +import { requireAuth, requirePermission } from "../middleware/auth"; import type { ChannelNotificationLevel, ForumTag } from "../../db/interfaces"; import { syncAllClients, broadcastMemberList, invalidateBroadcastDedupe } from "../utils/clients"; import { sendServerDetails } from "../utils/server"; @@ -15,6 +15,7 @@ import { ensureDefaultSidebarItems, listServerSidebarItems, upsertServerSidebarItem, + addChannelRowIfMissing, deleteServerSidebarItem, insertServerAudit, listPermissionTemplates, @@ -24,11 +25,19 @@ import { createPermissionScope, replacePermissionRules, setChannelPermissionScope, + setFolderPermissionScope, deletePermissionTemplate, + resolveChannelScopes, } from "../../db"; import { CHANNEL_PERMISSIONS } from "../../constants/permissions"; import { checkRateLimit, RateLimitRule } from "../../utils/rateLimiter"; -import { mayViewChannel, resetChannelPermissionCache } from "../../services/channelPermissions"; +import { + keepsEmptyFolders, + mayViewChannel, + resetChannelPermissionCache, + visibleChannelIds, + visibleSidebarItems, +} from "../../services/channelPermissions"; import { resetChannelIdCache } from "../utils/conversationAccess"; const RL_SETTINGS: RateLimitRule = { limit: 30, windowMs: 60_000, scorePerAction: 1, maxScore: 20, scoreDecayMs: 3_000 }; @@ -91,6 +100,118 @@ async function evictNewlyHiddenEverywhere(ctx: HandlerContext): Promise { } } +/** After a write that can change which scope decides a channel. Cache first, + then evict, then tell everybody, as in `server:channels:scope:set`. */ +async function refreshChannelAccess(ctx: HandlerContext): Promise { + const { io, clientsInfo, serverId } = ctx; + resetChannelPermissionCache(); + invalidateBroadcastDedupe(io); + await evictNewlyHiddenEverywhere(ctx); + syncAllClients(io, clientsInfo); + broadcastMemberList(io, clientsInfo, serverId); +} + +async function resolvedScopesNow() { + const [channels, items] = await Promise.all([listServerChannels(), listServerSidebarItems()]); + return { channels, items, scopes: resolveChannelScopes(channels, items) }; +} + +/** Whether a sidebar write changed the scope any channel reads. */ +function scopesDiffer( + before: Map, + after: Map, +): boolean { + if (before.size !== after.size) return true; + for (const [channelId, was] of before) { + if (after.get(channelId)?.scopeId !== was.scopeId) return true; + } + return false; +} + +/** A channel that follows its folder takes the next one's scope when it moves, + and choosing a scope is `manage_channels`, as in `…:scope:set`. */ +function movesChangeAccess( + { channels, items, scopes }: Awaited>, + moves: { channelId: string; parentItemId: string | null }[], +): boolean { + const folderScopes = new Map( + items.filter((i) => i.kind === "folder").map((i) => [i.item_id, i.permission_scope_id]), + ); + const channelById = new Map(channels.map((c) => [c.channel_id, c])); + + return moves.some(({ channelId, parentItemId }) => { + if (!parentItemId || !folderScopes.has(parentItemId)) return false; + // One not written yet follows, which is what a new channel does. + const channel = channelById.get(channelId); + if (channel && (channel.permission_scope_id || !channel.follows_folder)) return false; + const now = scopes.get(channelId); + if (now?.folderId === parentItemId) return false; + return (folderScopes.get(parentItemId) ?? null) !== (now?.scopeId ?? null); + }); +} + +/** A scope as the settings dialogs draw it: null is Everyone, a template shows + its name, and anything else is Custom. */ +async function describeScope(scopeId: string | null) { + const scope = scopeId ? await getPermissionScope(scopeId) : null; + return { + scopeId, + isTemplate: scope?.is_template ?? false, + name: scope?.name ?? null, + rules: scopeId + ? (await listPermissionRules(scopeId)).map((r) => ({ + roleId: r.role_id, + permission: r.permission, + effect: r.effect, + })) + : [], + }; +} + +/** Names without what they decide: pointing at "Staff only" shows you what it + does anyway, but the rules are not readable. */ +async function templateNames() { + return (await listPermissionTemplates()).map((t) => ({ id: t.scope_id, name: t.name, isSystem: t.is_system })); +} + +/** What a channel reads is its folder's while it follows one, so that is what + the dropdown shows. `followsFolder` and `folder` are absent on older servers. */ +async function channelScopeReply(channelId: string) { + const { items, scopes } = await resolvedScopesNow(); + const resolved = scopes.get(channelId); + const folder = resolved?.folderId ? items.find((i) => i.item_id === resolved.folderId) : undefined; + return { + channelId, + permissions: CHANNEL_PERMISSIONS, + ...(await describeScope(resolved?.scopeId ?? null)), + templates: await templateNames(), + followsFolder: resolved?.followsFolder ?? false, + folder: folder ? { id: folder.item_id, name: folder.label ?? null } : null, + }; +} + +/** The scope `templateId` or `custom` names for something now on `current`. + Undefined for a template that is not one; Custom reuses a private scope. */ +async function chosenScope( + current: string | null, + payload: { templateId?: string | null; custom?: boolean; rules?: { roleId: string; permission: string; effect: string }[] }, +): Promise { + if (payload.custom) { + // Never a template id or a folder's scope: both are shared. + const existing = current ? await getPermissionScope(current) : null; + const scopeId = existing && !existing.is_template + ? existing.scope_id + : await createPermissionScope({ isTemplate: false }); + await replacePermissionRules(scopeId, payload.rules ?? []); + return scopeId; + } + if (payload.templateId) { + const template = await getPermissionScope(payload.templateId); + return template?.is_template ? payload.templateId : undefined; + } + return null; +} + function broadcastDetails(ctx: HandlerContext) { const { io, clientsInfo, serverId } = ctx; for (const [sid, s] of io.sockets.sockets) { @@ -112,7 +233,7 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM try { const auth = await requireAuth(socket, payload, { permission: "manage_channels" }); if (!auth) return; - const chans = await listServerChannels(); + const { channels: chans, scopes } = await resolvedScopesNow(); socket.emit("server:channels", { serverId, channels: chans.map((c) => ({ @@ -126,7 +247,8 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM automated: c.automated || false, defaultNotificationLevel: channelNotificationLevel(c), forumTags: c.forum_tags, - permissionScopeId: c.permission_scope_id ?? null, + permissionScopeId: scopes.get(c.channel_id)?.scopeId ?? null, + followsFolder: scopes.get(c.channel_id)?.followsFolder ?? false, })), }); } catch (e) { @@ -142,6 +264,9 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM eSportsMode?: boolean; textInVoice?: boolean; layout?: "chat" | "forum"; automated?: boolean; forumTags?: ForumTag[]; defaultNotificationLevel?: ChannelNotificationLevel | null; + /** A new channel's folder, so its first row is there and it never shows + outside it. Ignored for a channel that exists. */ + parentItemId?: string | null; }) => { try { const rl = rlCheck("server:channels:upsert", ctx, RL_SETTINGS); @@ -182,17 +307,7 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM }); if (isNewChannel) { try { - const items = await listServerSidebarItems(); - const already = items.some((i) => i.kind === "channel" && i.channel_id === channelId); - if (!already) { - const end = items.reduce((max, i) => Math.max(max, i.position ?? 0), 0) + 10; - await upsertServerSidebarItem({ - itemId: `sb_ch_${channelId.slice(0, 54)}`, - kind: "channel", - channelId, - position: end, - }); - } + await addChannelRowIfMissing(channelId, payload.parentItemId ?? null); } catch (e) { consola.warn("could not add a sidebar row for the new channel", e); } @@ -287,18 +402,18 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM const auth = await requireAuth(socket, payload, { permission: "manage_roles" }); if (!auth) return; - const [templates, rulesByScope, channels] = await Promise.all([ + const [templates, rulesByScope, { scopes }] = await Promise.all([ listPermissionTemplates(), listAllPermissionRules(), - listServerChannels(), + resolvedScopesNow(), ]); - // Editing a template changes every channel using it at once, so this is - // the number somebody wants before they touch a row. + // Editing a template changes every channel using it at once, a folder's + // included, so this is the number somebody wants before they touch a row. const usedBy = new Map(); - for (const channel of channels) { - if (!channel.permission_scope_id) continue; - usedBy.set(channel.permission_scope_id, (usedBy.get(channel.permission_scope_id) ?? 0) + 1); + for (const { scopeId } of scopes.values()) { + if (!scopeId) continue; + usedBy.set(scopeId, (usedBy.get(scopeId) ?? 0) + 1); } socket.emit("server:permissions:templates", { @@ -373,7 +488,7 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM const auth = await requireAuth(socket, payload, { permission: "manage_roles" }); if (!auth) return; - // Channels using it go back to inheriting, which can only widen access. + // Channels and folders on it go to Everyone, which can only widen access. // No eviction needed: nobody loses a channel by this. await deletePermissionTemplate(payload.templateId); resetChannelPermissionCache(); @@ -397,38 +512,7 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM return; } - const [channels, templates] = await Promise.all([ - listServerChannels(), - listPermissionTemplates(), - ]); - const channel = channels.find((c) => c.channel_id === payload.channelId); - const scopeId = channel?.permission_scope_id ?? null; - const scope = scopeId ? await getPermissionScope(scopeId) : null; - - socket.emit("server:channels:scope", { - serverId, - channelId: payload.channelId, - permissions: CHANNEL_PERMISSIONS, - scopeId, - // Names without what they decide: pointing a channel at "Staff only" - // shows you what it does anyway, but the rules are not readable. - templates: templates.map((t) => ({ - id: t.scope_id, - name: t.name, - isSystem: t.is_system, - })), - // The client draws a dropdown from this: null is "Everyone", - // is_template is the template's name, and neither is "Custom". - isTemplate: scope?.is_template ?? false, - name: scope?.name ?? null, - rules: scopeId - ? (await listPermissionRules(scopeId)).map((r) => ({ - roleId: r.role_id, - permission: r.permission, - effect: r.effect, - })) - : [], - }); + socket.emit("server:channels:scope", { serverId, ...(await channelScopeReply(payload.channelId)) }); } catch (e) { consola.error("server:channels:scope:get failed", e); socket.emit("server:error", { error: "scope_failed", message: "Failed to read channel permissions." }); @@ -436,7 +520,7 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM }, /** `templateId` picks a template, `custom: true` writes `rules` into the - channel's private scope, neither goes back to inheriting. */ + channel's private scope, neither is Everyone. Each is the channel's own. */ 'server:channels:scope:set': async (payload: { accessToken: string; channelId: string; @@ -462,27 +546,14 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM return; } - if (payload.custom) { - // Reuse the channel's own scope, so Custom to template and back leaves - // none behind. Never a template id: that is shared. - const existing = channel.permission_scope_id - ? await getPermissionScope(channel.permission_scope_id) - : null; - const scopeId = existing && !existing.is_template - ? existing.scope_id - : await createPermissionScope({ isTemplate: false }); - await replacePermissionRules(scopeId, payload.rules ?? []); - await setChannelPermissionScope(channelId, scopeId); - } else if (payload.templateId) { - const template = await getPermissionScope(payload.templateId); - if (!template?.is_template) { - socket.emit("server:error", { error: "not_found", message: "No such template." }); - return; - } - await setChannelPermissionScope(channelId, payload.templateId); - } else { - await setChannelPermissionScope(channelId, null); + // The channel's own scope, not its folder's: Custom from a folder copies + // what it shows into a scope of the channel's own. + const scopeId = await chosenScope(channel.permission_scope_id, payload); + if (scopeId === undefined) { + socket.emit("server:error", { error: "not_found", message: "No such template." }); + return; } + await setChannelPermissionScope(channelId, scopeId); // Cache, then evict, then broadcast: off a stale cache the broadcast // names a channel that was just hidden. @@ -499,6 +570,112 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM } }, + /** Back to whatever its folder says, dropping the scope it had. The reply is + the dialog's: it is open on this channel and cannot know the folder's. */ + 'server:channels:scope:follow': async (payload: { accessToken: string; channelId: string }) => { + try { + const rl = rlCheck("server:channels:scope:follow", ctx, RL_SETTINGS); + if (!rl.allowed) { emitRateLimited(ctx, rl); return; } + if (!payload || typeof payload.channelId !== "string") { + socket.emit("server:error", { error: "invalid_payload", message: "channelId required." }); + return; + } + const auth = await requireAuth(socket, payload, { permission: "manage_channels" }); + if (!auth) return; + + const channelId = payload.channelId.trim(); + const channels = await listServerChannels(); + if (!channels.some((c) => c.channel_id === channelId)) { + socket.emit("server:error", { error: "not_found", message: "No such channel." }); + return; + } + + await setChannelPermissionScope(channelId, null, { followFolder: true }); + await refreshChannelAccess(ctx); + insertServerAudit({ actorServerUserId: auth.tokenPayload.serverUserId, action: "channel_scope_follow", target: channelId }).catch((e) => consola.warn("audit log write failed", e)); + socket.emit("server:channels:scope", { serverId, ...(await channelScopeReply(channelId)) }); + broadcastDetails(ctx); + } catch (e) { + consola.error("server:channels:scope:follow failed", e); + socket.emit("server:error", { error: "scope_set_failed", message: "Failed to set channel permissions." }); + } + }, + + /** As `server:channels:scope:get`, for a folder. `channelCount` is how many + of its channels follow it, so how many a change reaches. */ + 'server:folders:scope:get': async (payload: { accessToken: string; folderId: string }) => { + try { + const auth = await requireAuth(socket, payload, { permission: "manage_channels" }); + if (!auth) return; + if (!payload || typeof payload.folderId !== "string") { + socket.emit("server:error", { error: "invalid_payload", message: "folderId required." }); + return; + } + + const { items, scopes } = await resolvedScopesNow(); + const folder = items.find((i) => i.item_id === payload.folderId && i.kind === "folder"); + if (!folder) { + socket.emit("server:error", { error: "not_found", message: "No such folder." }); + return; + } + const following = [...scopes.values()].filter((r) => r.followsFolder && r.folderId === folder.item_id); + + socket.emit("server:folders:scope", { + serverId, + folderId: folder.item_id, + permissions: CHANNEL_PERMISSIONS, + ...(await describeScope(folder.permission_scope_id)), + templates: await templateNames(), + channelCount: following.length, + }); + } catch (e) { + consola.error("server:folders:scope:get failed", e); + socket.emit("server:error", { error: "scope_failed", message: "Failed to read folder permissions." }); + } + }, + + /** As `server:channels:scope:set`, for a folder, and so for every channel in + it that follows it. */ + 'server:folders:scope:set': async (payload: { + accessToken: string; + folderId: string; + templateId?: string | null; + custom?: boolean; + rules?: { roleId: string; permission: string; effect: string }[]; + }) => { + try { + const rl = rlCheck("server:folders:scope:set", ctx, RL_SETTINGS); + if (!rl.allowed) { emitRateLimited(ctx, rl); return; } + if (!payload || typeof payload.folderId !== "string") { + socket.emit("server:error", { error: "invalid_payload", message: "folderId required." }); + return; + } + const auth = await requireAuth(socket, payload, { permission: "manage_channels" }); + if (!auth) return; + + const items = await listServerSidebarItems(); + const folder = items.find((i) => i.item_id === payload.folderId.trim() && i.kind === "folder"); + if (!folder) { + socket.emit("server:error", { error: "not_found", message: "No such folder." }); + return; + } + + const scopeId = await chosenScope(folder.permission_scope_id, payload); + if (scopeId === undefined) { + socket.emit("server:error", { error: "not_found", message: "No such template." }); + return; + } + await setFolderPermissionScope(folder.item_id, scopeId); + + await refreshChannelAccess(ctx); + insertServerAudit({ actorServerUserId: auth.tokenPayload.serverUserId, action: "folder_scope_set", target: folder.item_id, meta: { templateId: payload.templateId ?? null, custom: Boolean(payload.custom) } }).catch((e) => consola.warn("audit log write failed", e)); + broadcastDetails(ctx); + } catch (e) { + consola.error("server:folders:scope:set failed", e); + socket.emit("server:error", { error: "scope_set_failed", message: "Failed to set folder permissions." }); + } + }, + 'server:channels:reorder': async (payload: { accessToken: string; order: string[] }) => { try { const rl = rlCheck("server:channels:reorder", ctx, RL_SETTINGS); @@ -542,7 +719,15 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM const auth = await requireAuth(socket, payload); if (!auth) return; if (await ensureDefaultSidebarItems()) resetChannelPermissionCache(); - const items = await listServerSidebarItems(); + const { serverUserId, grytUserId } = auth.tokenPayload; + const [all, visible, keepEmpty] = await Promise.all([ + listServerSidebarItems(), + visibleChannelIds(serverUserId, grytUserId), + keepsEmptyFolders(serverUserId, grytUserId), + ]); + // Filtered as `server:details` is: a hidden channel's row or a folder's + // name must not reach somebody by asking here instead. + const items = visibleSidebarItems(all, visible, keepEmpty); socket.emit("server:sidebar", { serverId, items: items.map((it) => ({ id: it.item_id, kind: it.kind, position: it.position, channelId: it.channel_id ?? null, spacerHeight: it.spacer_height ?? null, label: it.label ?? null })), @@ -568,23 +753,17 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM const auth = await requireAuth(socket, payload, { permission: "manage_sidebar" }); if (!auth) return; - await upsertServerSidebarItem({ itemId: payload.itemId, kind: payload.kind, position: payload.position, channelId: payload.channelId ?? null, spacerHeight: payload.spacerHeight ?? null, label: payload.label ?? null, parentItemId: payload.parentItemId ?? null }); - - /* One row per channel, or the sidebar draws it twice: the desktop sends - this right after `server:channels:upsert`. The payload's row wins. */ + const now = await resolvedScopesNow(); if (payload.kind === "channel" && payload.channelId) { - try { - const items = await listServerSidebarItems(); - for (const other of items) { - if (other.item_id === payload.itemId) continue; - if (other.kind !== "channel" || other.channel_id !== payload.channelId) continue; - await deleteServerSidebarItem(other.item_id); - } - } catch (e) { - consola.warn("could not clear duplicate sidebar rows", e); - } + const moves = [{ channelId: payload.channelId, parentItemId: payload.parentItemId ?? null }]; + if (movesChangeAccess(now, moves) && !requirePermission(socket, auth, "manage_channels")) return; } + const before = now.scopes; + await upsertServerSidebarItem({ itemId: payload.itemId, kind: payload.kind, position: payload.position, channelId: payload.channelId ?? null, spacerHeight: payload.spacerHeight ?? null, label: payload.label ?? null, parentItemId: payload.parentItemId ?? null }); + + resetChannelPermissionCache(); + if (scopesDiffer(before, (await resolvedScopesNow()).scopes)) await refreshChannelAccess(ctx); insertServerAudit({ actorServerUserId: auth.tokenPayload.serverUserId, action: "sidebar_item_upsert", target: payload.itemId, meta: { kind: payload.kind } }).catch((e) => consola.warn("audit log write failed", e)); broadcastDetails(ctx); } catch (e) { @@ -604,7 +783,10 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM const auth = await requireAuth(socket, payload, { permission: "manage_sidebar" }); if (!auth) return; + const before = (await resolvedScopesNow()).scopes; await deleteServerSidebarItem(payload.itemId); + resetChannelPermissionCache(); + if (scopesDiffer(before, (await resolvedScopesNow()).scopes)) await refreshChannelAccess(ctx); insertServerAudit({ actorServerUserId: auth.tokenPayload.serverUserId, action: "sidebar_item_delete", target: payload.itemId }).catch((e) => consola.warn("audit log write failed", e)); broadcastDetails(ctx); } catch (e) { @@ -629,22 +811,32 @@ export function registerAdminChannelHandlers(ctx: HandlerContext): EventHandlerM const auth = await requireAuth(socket, payload, { permission: "manage_sidebar" }); if (!auth) return; - const items = await listServerSidebarItems(); + const now = await resolvedScopesNow(); + const { items, scopes: before } = now; const byId = new Map(items.map((it) => [it.item_id, it])); - let pos = 10; - for (const entry of payload.order) { + const placed = payload.order.flatMap((entry) => { const isObject = typeof entry === "object" && entry !== null; const rawId = isObject ? entry.itemId : entry; const it = byId.get(String(rawId || "").trim()); - if (!it) continue; - + if (!it) return []; const parentItemId = isObject && "parentItemId" in entry ? entry.parentItemId ?? null : it.parent_item_id; + return [{ it, parentItemId }]; + }); + const moves = placed + .filter(({ it }) => it.kind === "channel" && it.channel_id) + .map(({ it, parentItemId }) => ({ channelId: it.channel_id as string, parentItemId })); + if (movesChangeAccess(now, moves) && !requirePermission(socket, auth, "manage_channels")) return; + + let pos = 10; + for (const { it, parentItemId } of placed) { await upsertServerSidebarItem({ itemId: it.item_id, kind: it.kind, position: pos, channelId: it.channel_id, spacerHeight: it.spacer_height, label: it.label, parentItemId }); pos += 10; } + resetChannelPermissionCache(); + if (scopesDiffer(before, (await resolvedScopesNow()).scopes)) await refreshChannelAccess(ctx); insertServerAudit({ actorServerUserId: auth.tokenPayload.serverUserId, action: "sidebar_reorder", meta: { order: payload.order } }).catch((e) => consola.warn("audit log write failed", e)); broadcastDetails(ctx); } catch (e) { diff --git a/src/socket/handlers/channelVisibilityLeaks.test.ts b/src/socket/handlers/channelVisibilityLeaks.test.ts index d881af93..40bf41d0 100644 --- a/src/socket/handlers/channelVisibilityLeaks.test.ts +++ b/src/socket/handlers/channelVisibilityLeaks.test.ts @@ -235,6 +235,14 @@ const PATHS: { // so both halves are the refusing half. Asserted separately below. skipPermittedHalf: true, }, + { + name: "server:sidebar:list — the sidebar asked for on its own", + run: async (who) => { + const h = harness(who); + await registerAdminChannelHandlers(h.ctx)["server:sidebar:list"]({ accessToken: who.accessToken }); + return h.mine(); + }, + }, { name: "chat:send — posting into a guessed id", run: async (who) => { diff --git a/src/socket/handlers/folderPermissions.test.ts b/src/socket/handlers/folderPermissions.test.ts new file mode 100644 index 00000000..3464eee5 --- /dev/null +++ b/src/socket/handlers/folderPermissions.test.ts @@ -0,0 +1,457 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import type { Permission } from "../../constants/permissions"; +import { initSqlite } from "../../db/sqlite/connection"; +import { listServerChannels, listServerSidebarItems, upsertServerChannel, upsertServerSidebarItem } from "../../db/sqlite/channels"; +import { listPermissionRules } from "../../db/sqlite/channelScopes"; +import { createRoleDefinition } from "../../db/sqlite/roleDefinitions"; +import { createServerConfigIfNotExists, setServerRole } from "../../db/sqlite/servers"; +import { upsertUser } from "../../db/sqlite/users"; +import { resetChannelPermissionCache } from "../../services/channelPermissions"; +import type { Clients } from "../../types"; +import { generateAccessToken } from "../../utils/jwt"; +import { broadcastMemberList } from "../utils/clients"; +import { resetChannelIdCache } from "../utils/conversationAccess"; +import { sendServerDetails } from "../utils/server"; +import { registerAdminChannelHandlers } from "./adminChannels"; +import { registerChatHandlers } from "./chat"; +import type { HandlerContext } from "./types"; +import { registerVoiceHandlers } from "./voice"; + +/** + * Every case runs the real handlers as the member it is about, and looks at what + * that member's socket was sent. A folder's name counts as leaked anywhere in it. + */ + +const HOST = "folders-perms.test:5001"; +const SERVER_ID = "folder-perms-test"; + +const FOLDER = "sb-fold-staff"; +const FOLDER_NAME = "Quartermaster"; +const STAFF_TEXT = "fp-staff-chat"; +const STAFF_VOICE = "fp-staff-room"; +const OWN = "fp-announcements"; +const OPEN_TEXT = "fp-open"; +const OPEN_VOICE = "fp-lounge"; +const LOOSE = "fp-loose"; + +const MEMBER_ROLE = "fp-member"; + +let dir: string; + +async function memberWith(name: string, rank: number, permissions: Permission[]) { + const roleId = `fp-${name}`; + const grytUserId = `account-fp-${name}`; + await createRoleDefinition(roleId, { name, rank, permissions }); + const user = await upsertUser(grytUserId, name); + await setServerRole(user.server_user_id, roleId); + return { + serverUserId: user.server_user_id, + grytUserId, + accessToken: generateAccessToken({ + grytUserId, + serverUserId: user.server_user_id, + nickname: name, + serverHost: HOST, + tokenVersion: 0, + }), + }; +} + +type Member = Awaited>; + +let member: Member; +let admin: Member; +let arranger: Member; + +interface Emitted { + to: string | null; + event: string; + payload: unknown; +} + +/** A granted room is what the positive cases look for, so the SFU says yes. */ +const fakeSfu = { + isConnected: () => true, + registerRoom: async () => {}, + generateClientJoinToken: (roomId: string, userId: string) => ({ room_id: roomId, user_token: "stub", user_id: userId }), + getActiveUsers: () => new Map(), + untrackUserConnection: () => {}, +}; + +/** Both members' sockets in one map, since the broadcasts pick their audience + out of it. `voice` seats somebody in a room. */ +function harness(self: Member, others: Member[] = [], voice: Record = {}) { + const emitted: Emitted[] = []; + const clientsInfo: Clients = {}; + const sockets = new Map>(); + + function makeSocket(id: string) { + return { + id, + handshake: { headers: { host: HOST }, address: "127.0.0.1" }, + rooms: new Set(["verifiedClients"]), + emit(event: string, payload?: unknown) { + emitted.push({ to: id, event, payload }); + return true; + }, + join() {}, + leave() {}, + to() { + return { emit(event: string, payload?: unknown) { emitted.push({ to: null, event, payload }); } }; + }, + }; + } + + for (const m of [self, ...others]) { + const id = `sock-${m.serverUserId}`; + sockets.set(id, makeSocket(id)); + clientsInfo[id] = { + serverUserId: m.serverUserId, + grytUserId: m.grytUserId, + nickname: m.grytUserId, + permissions: new Set(["view_members"]), + voiceChannelId: voice[m.serverUserId] ?? "", + isConnectedToVoice: Boolean(voice[m.serverUserId]), + hasJoinedChannel: Boolean(voice[m.serverUserId]), + } as unknown as Clients[string]; + } + + const io = { + to() { + return { emit(event: string, payload?: unknown) { emitted.push({ to: null, event, payload }); } }; + }, + emit(event: string, payload?: unknown) { emitted.push({ to: null, event, payload }); }, + sockets: { sockets }, + }; + + const clientId = `sock-${self.serverUserId}`; + const ctx = { + io, + socket: sockets.get(clientId), + clientId, + serverId: SERVER_ID, + clientsInfo, + sfuClient: fakeSfu, + getClientIp: () => `10.9.${self.serverUserId.length}.1`, + clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + + const to = (m: Member) => emitted.filter((e) => e.to === null || e.to === `sock-${m.serverUserId}`); + return { ctx, emitted, clientsInfo, socket: sockets.get(clientId)!, to }; +} + +async function asAdmin(event: string, payload: Record) { + const h = harness(admin); + await registerAdminChannelHandlers(h.ctx)[event]({ accessToken: admin.accessToken, ...payload }); + const refused = h.emitted.filter((e) => e.event === "server:error"); + assert.deepEqual(refused, [], `${event} refused the admin`); + return h; +} + +/** What `server:details` and `server:sidebar:list` hand this member, together. */ +async function whatTheySee(who: Member): Promise { + const h = harness(who); + await sendServerDetails(h.socket as never, h.clientsInfo, SERVER_ID); + await registerAdminChannelHandlers(h.ctx)["server:sidebar:list"]({ accessToken: who.accessToken }); + return JSON.stringify(h.to(who)); +} + +async function fetchAs(who: Member, conversationId: string) { + const h = harness(who); + await registerChatHandlers(h.ctx)["chat:fetch"]({ conversationId }); + return h.to(who).map((e) => ({ event: e.event, payload: e.event === "chat:history" ? "history" : e.payload })); +} + +async function sendAs(who: Member, conversationId: string) { + const h = harness(who); + await registerChatHandlers(h.ctx)["chat:send"]({ conversationId, accessToken: who.accessToken, text: "hello?" }); + return h.to(who).filter((e) => e.event === "chat:error").map((e) => e.payload); +} + +async function joinAs(who: Member, roomId: string) { + const h = harness(who); + await registerVoiceHandlers(h.ctx)["voice:room:request"](roomId); + return h.to(who).filter((e) => e.event.startsWith("voice:room:")); +} + +async function scopeOf(channelId: string) { + const h = await asAdmin("server:channels:scope:get", { channelId }); + return h.emitted.find((e) => e.event === "server:channels:scope")?.payload as { + scopeId: string | null; + isTemplate: boolean; + rules: { roleId: string; permission: string; effect: string }[]; + followsFolder?: boolean; + folder?: { id: string; name: string | null } | null; + }; +} + +const denyReading = [{ roleId: MEMBER_ROLE, permission: "read_messages", effect: "deny" }]; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-folder-perms-")); + process.env.DATA_DIR = dir; + await initSqlite(); + await createServerConfigIfNotExists(); + + const basics: Permission[] = ["read_messages", "send_messages", "join_voice", "speak", "view_members"]; + member = await memberWith("member", 10, basics); + admin = await memberWith("admin", 90, [...basics, "manage_channels", "manage_sidebar", "manage_roles"]); + arranger = await memberWith("arranger", 50, [...basics, "manage_sidebar"]); + + await upsertServerChannel({ channelId: OPEN_TEXT, name: "Open", type: "text" }); + await upsertServerChannel({ channelId: OPEN_VOICE, name: "Lounge", type: "voice" }); + await upsertServerChannel({ channelId: STAFF_TEXT, name: "Staff chat", type: "text" }); + await upsertServerChannel({ channelId: STAFF_VOICE, name: "Staff room", type: "voice" }); + await upsertServerChannel({ channelId: OWN, name: "Announcements", type: "text" }); + await upsertServerChannel({ channelId: LOOSE, name: "Loose", type: "text" }); + + await upsertServerSidebarItem({ itemId: "sb-open", kind: "channel", channelId: OPEN_TEXT, position: 10 }); + await upsertServerSidebarItem({ itemId: "sb-lounge", kind: "channel", channelId: OPEN_VOICE, position: 20 }); + await upsertServerSidebarItem({ itemId: FOLDER, kind: "folder", label: FOLDER_NAME, position: 30 }); + await upsertServerSidebarItem({ itemId: "sb-staff-chat", kind: "channel", channelId: STAFF_TEXT, position: 40, parentItemId: FOLDER }); + await upsertServerSidebarItem({ itemId: "sb-staff-room", kind: "channel", channelId: STAFF_VOICE, position: 50, parentItemId: FOLDER }); + await upsertServerSidebarItem({ itemId: "sb-announcements", kind: "channel", channelId: OWN, position: 60, parentItemId: FOLDER }); + await upsertServerSidebarItem({ itemId: "sb-loose", kind: "channel", channelId: LOOSE, position: 70 }); + + resetChannelPermissionCache(); + resetChannelIdCache(); +}); + +after(() => { + delete process.env.DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("a folder whose permissions hide it from a member", () => { + it("is an ordinary folder before it has any", async () => { + const seen = await whatTheySee(member); + assert.ok(seen.includes(FOLDER_NAME), "the member should see a folder nobody has narrowed"); + assert.ok(seen.includes(STAFF_TEXT)); + }); + + it("turns the member out of a voice room in it when set", async () => { + const h = harness(admin, [member], { [member.serverUserId]: STAFF_VOICE }); + await registerAdminChannelHandlers(h.ctx)["server:folders:scope:set"]({ + accessToken: admin.accessToken, + folderId: FOLDER, + custom: true, + rules: denyReading, + }); + + assert.deepEqual(h.emitted.filter((e) => e.event === "server:error"), []); + assert.ok( + h.to(member).some((e) => e.event === "voice:room:leave"), + "the member kept a seat in a room their folder just hid", + ); + assert.equal(h.clientsInfo[`sock-${member.serverUserId}`].voiceChannelId, ""); + }); + + it("never names the folder, or any channel in it, to the member", async () => { + const seen = await whatTheySee(member); + for (const hidden of [FOLDER, FOLDER_NAME, STAFF_TEXT, STAFF_VOICE, OWN]) { + assert.equal(seen.includes(hidden), false, `"${hidden}" reached the member:\n${seen}`); + } + assert.ok(seen.includes(OPEN_TEXT), "filtering took the open channel with it"); + }); + + it("still shows the folder and its channels to the admin", async () => { + const seen = await whatTheySee(admin); + for (const shown of [FOLDER_NAME, STAFF_TEXT, STAFF_VOICE, OWN]) { + assert.ok(seen.includes(shown), `the admin lost "${shown}"`); + } + }); + + it("refuses the member's read the way it refuses a channel that is not there", async () => { + assert.deepEqual(await fetchAs(member, STAFF_TEXT), await fetchAs(member, "no-such-channel")); + assert.deepEqual(await fetchAs(member, OPEN_TEXT), [{ event: "chat:history", payload: "history" }]); + }); + + it("refuses the member's message", async () => { + assert.deepEqual(await sendAs(member, STAFF_TEXT), await sendAs(member, "no-such-channel")); + assert.deepEqual(await sendAs(member, OPEN_TEXT), [], "the open channel should take the message"); + }); + + it("refuses the member a seat in its voice room", async () => { + const refused = await joinAs(member, STAFF_VOICE); + assert.equal(refused.some((e) => e.event === "voice:room:granted"), false); + assert.deepEqual(refused.map((e) => (e.payload as { error?: string }).error), ["not_found"]); + assert.ok((await joinAs(member, OPEN_VOICE)).some((e) => e.event === "voice:room:granted")); + }); + + it("keeps the room out of the member list for the member", async () => { + const h = harness(member, [admin], { [admin.serverUserId]: STAFF_VOICE }); + broadcastMemberList(h.ctx.io as never, h.clientsInfo, SERVER_ID); + await new Promise((r) => setTimeout(r, 300)); + const lists = h.to(member).filter((e) => e.event === "members:list"); + assert.ok(lists.length > 0, "members:list was never sent"); + assert.equal(JSON.stringify(lists).includes(STAFF_VOICE), false); + }); + + it("tells the channel editor the channel follows its folder", async () => { + const scope = await scopeOf(STAFF_TEXT); + assert.equal(scope.followsFolder, true); + assert.deepEqual(scope.folder, { id: FOLDER, name: FOLDER_NAME }); + assert.equal(scope.isTemplate, false, "a folder's own rules draw as Custom"); + assert.deepEqual(scope.rules, denyReading); + }); +}); + +describe("a channel with permissions of its own", () => { + it("keeps them inside a hidden folder, and brings the folder back with it", async () => { + await asAdmin("server:channels:scope:set", { channelId: OWN, templateId: null }); + + const scope = await scopeOf(OWN); + assert.equal(scope.followsFolder, false); + assert.equal(scope.scopeId, null, "picking Everyone is a choice of the channel's own"); + + const seen = await whatTheySee(member); + assert.ok(seen.includes(OWN), "the member should see a channel open to everyone"); + assert.ok(seen.includes(FOLDER_NAME), "a folder with a channel they can see comes back"); + assert.equal(seen.includes(STAFF_TEXT), false, "only that channel, not its neighbours"); + }); + + it("follows the folder again when told to", async () => { + const h = await asAdmin("server:channels:scope:follow", { channelId: OWN }); + const reply = h.emitted.find((e) => e.event === "server:channels:scope")?.payload as { followsFolder: boolean }; + assert.equal(reply?.followsFolder, true, "the dialog should hear it follows again"); + + const seen = await whatTheySee(member); + assert.equal(seen.includes(OWN), false); + assert.equal(seen.includes(FOLDER_NAME), false); + }); +}); + +describe("moving channels in and out of the folder", () => { + it("makes a channel moved in follow the folder", async () => { + await asAdmin("server:sidebar:reorder", { order: [{ itemId: "sb-loose", parentItemId: FOLDER }] }); + assert.equal((await whatTheySee(member)).includes(LOOSE), false); + assert.equal((await scopeOf(LOOSE)).followsFolder, true); + }); + + it("lets a channel with its own permissions keep them when it moves in", async () => { + await asAdmin("server:sidebar:reorder", { order: [{ itemId: "sb-loose", parentItemId: null }] }); + await asAdmin("server:channels:scope:set", { channelId: LOOSE, templateId: null }); + await asAdmin("server:sidebar:reorder", { order: [{ itemId: "sb-loose", parentItemId: FOLDER }] }); + + assert.ok((await whatTheySee(member)).includes(LOOSE), "its own Everyone should outrank the folder"); + assert.equal((await scopeOf(LOOSE)).followsFolder, false); + }); + + it("keeps the folder's rules on a channel dragged out to the top", async () => { + await asAdmin("server:sidebar:reorder", { order: [{ itemId: "sb-staff-chat", parentItemId: null }] }); + + const scope = await scopeOf(STAFF_TEXT); + assert.equal(scope.followsFolder, false, "at the top level it has nothing to follow"); + assert.deepEqual(scope.rules, denyReading, "it keeps what it had"); + assert.equal((await whatTheySee(member)).includes(STAFF_TEXT), false, "dragging it out opened it"); + + await asAdmin("server:sidebar:reorder", { order: [{ itemId: "sb-staff-chat", parentItemId: FOLDER }] }); + await asAdmin("server:channels:scope:follow", { channelId: STAFF_TEXT }); + }); + + it("needs manage_channels to move a following channel into a folder with other rules", async () => { + const h = harness(arranger); + await registerAdminChannelHandlers(h.ctx)["server:sidebar:reorder"]({ + accessToken: arranger.accessToken, + order: [{ itemId: "sb-staff-chat", parentItemId: null }, { itemId: "sb-open", parentItemId: FOLDER }], + }); + const refused = h.emitted.find((e) => e.event === "server:error")?.payload as { permission?: string }; + assert.equal(refused?.permission, "manage_channels"); + + const open = (await listServerSidebarItems()).find((i) => i.item_id === "sb-open"); + assert.equal(open?.parent_item_id, null, "a refused reorder should move nothing"); + }); + + it("lets somebody with only manage_sidebar reorder inside the folder", async () => { + const h = harness(arranger); + await registerAdminChannelHandlers(h.ctx)["server:sidebar:reorder"]({ + accessToken: arranger.accessToken, + order: [ + { itemId: "sb-staff-room", parentItemId: FOLDER }, + { itemId: "sb-staff-chat", parentItemId: FOLDER }, + ], + }); + assert.deepEqual(h.emitted.filter((e) => e.event === "server:error"), []); + }); + + it("creates a channel in the folder without showing it outside first", async () => { + const h = harness(admin, [member]); + await registerAdminChannelHandlers(h.ctx)["server:channels:upsert"]({ + accessToken: admin.accessToken, + channelId: "fp-new", + name: "New in staff", + type: "text", + parentItemId: FOLDER, + }); + await new Promise((r) => setTimeout(r, 50)); + assert.equal(JSON.stringify(h.to(member)).includes("fp-new"), false, "the new channel was broadcast to the member"); + assert.equal((await scopeOf("fp-new")).followsFolder, true); + }); + + it("keeps the folder's rules on its channels when the folder is deleted", async () => { + await asAdmin("server:sidebar:item:upsert", { itemId: "sb-fold-temp", kind: "folder", label: "Temporary", position: 80 }); + await asAdmin("server:folders:scope:set", { folderId: "sb-fold-temp", custom: true, rules: denyReading }); + await asAdmin("server:sidebar:reorder", { order: [{ itemId: "sb-loose", parentItemId: "sb-fold-temp" }] }); + await asAdmin("server:channels:scope:follow", { channelId: LOOSE }); + assert.equal((await whatTheySee(member)).includes(LOOSE), false); + + await asAdmin("server:sidebar:item:delete", { itemId: "sb-fold-temp" }); + + assert.equal((await whatTheySee(member)).includes(LOOSE), false, "deleting the folder opened its channel"); + const loose = (await listServerChannels()).find((c) => c.channel_id === LOOSE); + assert.ok(loose?.permission_scope_id, "the channel should have its own copy now"); + assert.deepEqual( + (await listPermissionRules(loose.permission_scope_id)).map((r) => ({ roleId: r.role_id, permission: r.permission, effect: r.effect })), + denyReading, + ); + }); +}); + +describe("a folder on a template", () => { + let templateId = ""; + + it("counts the channels following it as the template's", async () => { + const saved = await asAdmin("server:permissions:template:save", { name: "Staff only", rules: denyReading }); + assert.ok(saved); + const list = await asAdmin("server:permissions:templates:list", {}); + const templates = (list.emitted.find((e) => e.event === "server:permissions:templates")?.payload as { + templates: { id: string; name: string; channelCount: number }[]; + }).templates; + templateId = templates.find((t) => t.name === "Staff only")!.id; + + await asAdmin("server:folders:scope:set", { folderId: FOLDER, templateId }); + const after = await asAdmin("server:permissions:templates:list", {}); + const counted = (after.emitted.find((e) => e.event === "server:permissions:templates")?.payload as { + templates: { id: string; channelCount: number }[]; + }).templates.find((t) => t.id === templateId); + assert.equal(counted?.channelCount, 4, "staff chat, staff room, announcements and the new one follow it"); + assert.equal((await whatTheySee(member)).includes(FOLDER_NAME), false); + }); + + it("opens the folder when the template is deleted", async () => { + await asAdmin("server:permissions:template:delete", { templateId }); + const seen = await whatTheySee(member); + assert.ok(seen.includes(FOLDER_NAME)); + assert.ok(seen.includes(STAFF_TEXT)); + }); +}); + +describe("a folder that shuts its rooms without hiding them", () => { + it("shows the room and refuses the seat", async () => { + await asAdmin("server:folders:scope:set", { + folderId: FOLDER, + custom: true, + rules: [{ roleId: MEMBER_ROLE, permission: "join_voice", effect: "deny" }], + }); + + assert.ok((await whatTheySee(member)).includes(STAFF_VOICE), "join_voice alone should not hide the room"); + const refused = await joinAs(member, STAFF_VOICE); + assert.equal(refused.some((e) => e.event === "voice:room:granted"), false, "the SFU was asked for a shut room"); + assert.equal((refused[0]?.payload as { permission?: string })?.permission, "join_voice"); + }); +}); diff --git a/src/socket/handlers/permissionGates.test.ts b/src/socket/handlers/permissionGates.test.ts index be160eca..e6a5859a 100644 --- a/src/socket/handlers/permissionGates.test.ts +++ b/src/socket/handlers/permissionGates.test.ts @@ -190,6 +190,9 @@ const TOKEN_GATES: { { event: "server:sidebar:item:upsert", permission: "manage_sidebar", payload: { itemId: "i1", kind: "separator" } }, { event: "server:sidebar:item:delete", permission: "manage_sidebar", payload: { itemId: "i1" } }, { event: "server:sidebar:reorder", permission: "manage_sidebar", payload: { order: [] } }, + { event: "server:channels:scope:follow", permission: "manage_channels", payload: { channelId: "c1" } }, + { event: "server:folders:scope:get", permission: "manage_channels", payload: { folderId: "f1" } }, + { event: "server:folders:scope:set", permission: "manage_channels", payload: { folderId: "f1" } }, { event: "server:user:replace", permission: "replace_identity", payload: { targetServerUserId: "user_x", newGrytUserId: "account-new" } }, { event: "server:audit:list", permission: "view_audit_log" }, { event: "server:version:check", permission: "view_server_status", refusalOnly: true }, diff --git a/src/socket/handlers/voice.ts b/src/socket/handlers/voice.ts index 4b51e0bb..f7a0fd6a 100644 --- a/src/socket/handlers/voice.ts +++ b/src/socket/handlers/voice.ts @@ -347,6 +347,17 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { }); return; } + // A room can be visible and shut: the scope's `join_voice`, which the + // client already draws as `canJoin`. + if (access.kind === "channel" && !(await mayInChannel(roomId, userId, "join_voice", clientsInfo[clientId]?.grytUserId))) { + consola.warn(`[Voice:Step 1] REFUSED client=${clientId} user=${userId} room=${roomId} reason=join_voice`); + socket.emit("voice:room:error", { + error: "forbidden", + message: "You do not have permission to join this voice channel.", + permission: "join_voice", + }); + return; + } if (!sfuClient) { consola.error(`[Voice:Step 2] SFU client not initialized`); diff --git a/src/socket/utils/server.ts b/src/socket/utils/server.ts index e52a2b3a..9618f275 100644 --- a/src/socket/utils/server.ts +++ b/src/socket/utils/server.ts @@ -5,7 +5,14 @@ import { publicClientList } from "./clients"; import type { ChannelNotificationLevel, JoinPolicy, RoleDefinitionRecord } from "../../db/interfaces"; import { FALLBACK_ROLE_ID, PERMISSIONS } from "../../constants/permissions"; import { getEffectiveStanding } from "../../services/permissions"; -import { joinableChannelIds, postableChannelIds, resetChannelPermissionCache, visibleChannelIds } from "../../services/channelPermissions"; +import { + joinableChannelIds, + keepsEmptyFolders, + postableChannelIds, + resetChannelPermissionCache, + visibleChannelIds, + visibleSidebarItems, +} from "../../services/channelPermissions"; import { getAcceptedIdentityTiers } from "../../auth/identity"; import { announcedPlugins } from "../../plugins"; import { getVoiceSeatLimit } from "../../utils/voiceSeats"; @@ -23,6 +30,7 @@ import { listRoleDefinitions, listServerChannels, listServerSidebarItems, + resolveChannelScopes, } from "../../db"; // Module-level references set by socketHandler so REST routes can trigger broadcasts @@ -235,21 +243,23 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in ]); const channelById = new Map(allChannels.map((c) => [c.channel_id, c])); + const scopes = resolveChannelScopes(allChannels, allItems); // Both arrays below derive from `items`. Filtering `channels` alone leaves // the id, position and label in `sidebar_items`, which the client draws. - const [visible, postable, joinable] = await Promise.all([ + const [visible, postable, joinable, keepEmpty] = await Promise.all([ visibleChannelIds(client.serverUserId, client.grytUserId), // What the client draws a composer and an unlocked voice room for. // `chat:send` and the voice grant still decide. postableChannelIds(client.serverUserId, client.grytUserId), joinableChannelIds(client.serverUserId, client.grytUserId), + keepsEmptyFolders(client.serverUserId, client.grytUserId), ]); visibleToThem = visible; - const items = allItems.filter((it) => it.kind !== "channel" || !it.channel_id || visible.has(it.channel_id)); + // A folder goes with its last visible channel, so its name goes with it too. + const items = visibleSidebarItems(allItems, visible, keepEmpty); - /* Without `parentItemId` the client has folders it cannot fill. A folder is - never filtered above, so an all-hidden one arrives empty, not broken. */ + // Without `parentItemId` the client has folders it cannot fill. sidebar_items = items.map((it) => ({ id: it.item_id, kind: it.kind, @@ -279,7 +289,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in automated: c.automated || false, defaultNotificationLevel: channelNotificationLevel(c), forumTags: c.forum_tags, - permissionScopeId: c.permission_scope_id ?? null, + permissionScopeId: scopes.get(c.channel_id)?.scopeId ?? null, canSend: postable.has(c.channel_id), canJoin: joinable.has(c.channel_id), }]; @@ -302,7 +312,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in automated: c.automated || false, defaultNotificationLevel: channelNotificationLevel(c), forumTags: c.forum_tags, - permissionScopeId: c.permission_scope_id ?? null, + permissionScopeId: scopes.get(c.channel_id)?.scopeId ?? null, canSend: postable.has(c.channel_id), canJoin: joinable.has(c.channel_id), })); @@ -397,6 +407,9 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in /** What code sits between a member and the people they talk to, so it is not configurable. No version: that names which known problem applies. */ plugins: announcedPlugins(), + /** A constant: folders carry permissions here. Absent on an older server, + which ignores the folder scope events. */ + folder_permissions: true, }, };