From 9e6ff47194b344067ce95c63a59bbc859219296f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 10:50:11 +0000 Subject: [PATCH] feat(server,admin): private channels via per-role permission overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read side of channel visibility was already complete — channel_overrides rows deny ReadMessages and every list/focus/send/voice path enforces them — but nothing could write those rows. Add the missing write path and UI: - db: UpsertChannelOverride / DeleteChannelOverride / ListChannelRoleOverrides (roles LEFT JOIN overrides so the UI gets everything in one call) - admin API: GET/PUT/DELETE /admin/api/channels/{id}/permissions[/{roleId}] with unknown permission bits masked via the new permissions.AllPerms, audit logging, and immediate permission-cache invalidation - ws: Hub.RefreshChannelVisibility sends targeted channel_create / channel_delete to connected clients after an override change, unsubscribes hidden clients from the channel topic, and clears their focus. Sent outside the sequenced replay path on purpose: a replayed channel_delete would be filtered by the post-change allowed-channel set, inverting its audience. - admin panel: per-channel Access modal (lock icon) with per-role "Can access" checkboxes; unchecking writes deny = ReadMessages|ConnectVoice Known limits (follow-ups): users offline during a revoke keep a stale sidebar entry until their next fresh connect (server still denies access), and users already in a voice channel are not kicked when it goes private. Closes #93 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK --- .../tests/unit/screen-share-fps.test.ts | 4 +- Server/admin/api.go | 3 + Server/admin/api_test.go | 28 +- Server/admin/handlers_channel_perms.go | 161 +++++++++++ Server/admin/handlers_channel_perms_test.go | 255 ++++++++++++++++++ Server/admin/middleware_and_spawn_test.go | 1 + Server/admin/static/index.html | 45 +++- Server/admin/types.go | 4 + Server/db/channel_queries.go | 75 ++++++ Server/db/channel_queries_test.go | 90 +++++++ Server/permissions/permissions.go | 7 + Server/ws/hub.go | 63 +++++ Server/ws/hub_test.go | 94 +++++++ 13 files changed, 819 insertions(+), 11 deletions(-) create mode 100644 Server/admin/handlers_channel_perms.go create mode 100644 Server/admin/handlers_channel_perms_test.go diff --git a/Client/tauri-client/tests/unit/screen-share-fps.test.ts b/Client/tauri-client/tests/unit/screen-share-fps.test.ts index e8ae1e48..7bfd0491 100644 --- a/Client/tauri-client/tests/unit/screen-share-fps.test.ts +++ b/Client/tauri-client/tests/unit/screen-share-fps.test.ts @@ -83,9 +83,7 @@ describe("screen share FPS", () => { it("scales bitrate up for 60 and 120 fps", () => { expect(getScreenShareMaxBitrate("high", 60)).toBe(SCREENSHARE_PUBLISH_BITRATES.high * 1.5); - expect(getScreenShareMaxBitrate("source", 120)).toBe( - SCREENSHARE_PUBLISH_BITRATES.source * 2, - ); + expect(getScreenShareMaxBitrate("source", 120)).toBe(SCREENSHARE_PUBLISH_BITRATES.source * 2); }); }); diff --git a/Server/admin/api.go b/Server/admin/api.go index 276bbb7e..8db87f8f 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -46,6 +46,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Post("/channels", handleCreateChannel(database, hub)) r.Patch("/channels/{id}", handlePatchChannel(database, hub)) r.Delete("/channels/{id}", handleDeleteChannel(database, hub)) + r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database)) + r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator)) + r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator)) r.Get("/audit-log", handleGetAuditLog(database)) r.Get("/settings", handleGetSettings(database)) r.Patch("/settings", handlePatchSettings(database)) diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 3107b3d3..71558e11 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -87,6 +87,15 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_video INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -1107,13 +1116,14 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { // mockHub records which broadcast methods were called and with what arguments. type mockHub struct { - restartCalls []restartCall - channelCreates []*db.Channel - channelUpdates []*db.Channel - channelDeleteIDs []int64 - memberBanIDs []int64 - memberUpdates []memberUpdateCall - clientCount int + restartCalls []restartCall + channelCreates []*db.Channel + channelUpdates []*db.Channel + channelDeleteIDs []int64 + memberBanIDs []int64 + memberUpdates []memberUpdateCall + visibilityRefreshes []*db.Channel + clientCount int } type memberUpdateCall struct { @@ -1150,6 +1160,10 @@ func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) { m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName}) } +func (m *mockHub) RefreshChannelVisibility(ch *db.Channel) { + m.visibilityRefreshes = append(m.visibilityRefreshes, ch) +} + func (m *mockHub) ClientCount() int { return m.clientCount } diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go new file mode 100644 index 00000000..0676f51c --- /dev/null +++ b/Server/admin/handlers_channel_perms.go @@ -0,0 +1,161 @@ +package admin + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// ─── Channel Permission Override Handlers ──────────────────────────────────── +// +// These endpoints manage per-role allow/deny permission overrides on a +// channel (the channel_overrides table). Denying ReadMessages hides the +// channel from a role entirely ("private channel"); the read side is already +// enforced by ListVisibleChannels, the WS ready payload, and the per-message +// permission checks. + +// getPermChannel loads the channel for an override request and writes the +// appropriate error response when it is missing or a DM. Returns nil when a +// response has already been written. +func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db.Channel { + id, err := pathInt64(r, "id") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") + return nil + } + ch, err := database.GetChannel(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") + return nil + } + if ch == nil { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found") + return nil + } + if ch.Type == "dm" { + writeErr(w, http.StatusBadRequest, "INVALID_INPUT", "DM channels do not support permission overrides") + return nil + } + return ch +} + +// channelPermissionsResponse is the JSON shape for GET .../permissions. +type channelPermissionsResponse struct { + ChannelID int64 `json:"channel_id"` + Roles []db.ChannelRoleOverride `json:"roles"` +} + +func handleGetChannelPermissions(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + overrides, err := database.ListChannelRoleOverrides(ch.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions") + return + } + writeJSON(w, http.StatusOK, channelPermissionsResponse{ChannelID: ch.ID, Roles: overrides}) + } +} + +// putChannelPermissionRequest is the JSON body for PUT .../permissions/{roleId}. +type putChannelPermissionRequest struct { + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + roleID, err := pathInt64(r, "roleId") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") + return + } + role, err := database.GetRoleByID(roleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role") + return + } + if role == nil { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "role not found") + return + } + + var req putChannelPermissionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + // Drop unknown bits so garbage input cannot persist undefined perms. + allow := req.Allow & permissions.AllPerms + deny := req.Deny & permissions.AllPerms + + if err := database.UpsertChannelOverride(ch.ID, roleID, allow, deny); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission") + return + } + + actor := actorFromContext(r) + slog.Info("channel permissions updated", "actor_id", actor, "channel_id", ch.ID, + "role_id", roleID, "allow", allow, "deny", deny) + _ = database.LogAudit(actor, "channel_perms_update", "channel", ch.ID, + fmt.Sprintf("set overrides for role %s on #%s (allow=%#x deny=%#x)", role.Name, ch.Name, allow, deny)) + + if permInvalidator != nil { + permInvalidator.InvalidateAll() + } + if hub != nil { + hub.RefreshChannelVisibility(ch) + } + writeJSON(w, http.StatusOK, db.ChannelRoleOverride{ + RoleID: role.ID, + RoleName: role.Name, + Position: role.Position, + Permissions: role.Permissions, + Allow: allow, + Deny: deny, + }) + } +} + +func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + roleID, err := pathInt64(r, "roleId") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") + return + } + + if err := database.DeleteChannelOverride(ch.ID, roleID); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission") + return + } + + actor := actorFromContext(r) + slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID) + _ = database.LogAudit(actor, "channel_perms_clear", "channel", ch.ID, + fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name)) + + if permInvalidator != nil { + permInvalidator.InvalidateAll() + } + if hub != nil { + hub.RefreshChannelVisibility(ch) + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go new file mode 100644 index 00000000..4af4543f --- /dev/null +++ b/Server/admin/handlers_channel_perms_test.go @@ -0,0 +1,255 @@ +package admin_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// mockPermInvalidator records permission-cache invalidation calls. +type mockPermInvalidator struct { + invalidateUserIDs []int64 + invalidateAllN int +} + +func (m *mockPermInvalidator) InvalidateUser(userID int64) { + m.invalidateUserIDs = append(m.invalidateUserIDs, userID) +} + +func (m *mockPermInvalidator) InvalidateAll() { + m.invalidateAllN++ +} + +// ─── GET /channels/{id}/permissions ────────────────────────────────────────── + +func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodGet, "/channels/1/permissions", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var resp struct { + ChannelID int64 `json:"channel_id"` + Roles []db.ChannelRoleOverride `json:"roles"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.ChannelID != chID && resp.ChannelID != 1 { + t.Errorf("channel_id = %d", resp.ChannelID) + } + if len(resp.Roles) != 3 { + t.Fatalf("expected 3 roles, got %d", len(resp.Roles)) + } + if resp.Roles[0].RoleName != "Owner" { + t.Errorf("first role = %q, want Owner (position desc)", resp.Roles[0].RoleName) + } + for _, role := range resp.Roles { + if role.Allow != 0 || role.Deny != 0 { + t.Errorf("role %d: expected zero overrides, got (%#x, %#x)", role.RoleID, role.Allow, role.Deny) + } + } +} + +func TestGetChannelPermissions_NotFound(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestGetChannelPermissions_DMRejected(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("dm-chan", "dm", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel dm: %v", err) + } + + w := doRequest(t, handler, http.MethodGet, + "/channels/"+itoa(chID)+"/permissions", token, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } +} + +// ─── PUT /channels/{id}/permissions/{roleId} ───────────────────────────────── + +func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + denyPrivate := permissions.ReadMessages | permissions.ConnectVoice + body := map[string]any{"allow": 0, "deny": denyPrivate} + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", token, body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != denyPrivate { + t.Errorf("persisted override = (%#x, %#x), want (0, %#x)", allow, deny, denyPrivate) + } + + if inv.invalidateAllN != 1 { + t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN) + } + if len(hub.visibilityRefreshes) != 1 || hub.visibilityRefreshes[0].ID != chID { + t.Errorf("RefreshChannelVisibility not called for channel %d", chID) + } + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + found := false + for _, e := range entries { + if e.Action == "channel_perms_update" { + found = true + } + } + if !found { + t.Error("expected channel_perms_update audit entry") + } +} + +func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret2", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // 0x4 and 0x8 are undefined bits — they must be dropped. + body := map[string]any{"allow": 0x4 | int64(permissions.SendMessages), "deny": 0x8} + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", token, body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != permissions.SendMessages { + t.Errorf("allow = %#x, want %#x (unknown bits dropped)", allow, permissions.SendMessages) + } + if deny != 0 { + t.Errorf("deny = %#x, want 0 (unknown bits dropped)", deny) + } +} + +func TestPutChannelPermission_UnknownRole(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret3", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/999", token, map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } +} + +func TestPutChannelPermission_NonAdminForbidden(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + _ = createAdminUser(t, database) + memberToken := createMemberUser(t, database) + + chID, err := database.CreateChannel("secret4", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", memberToken, map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 403/401; body: %s", w.Code, w.Body.String()) + } +} + +// ─── DELETE /channels/{id}/permissions/{roleId} ────────────────────────────── + +func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret5", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelOverride(chID, 3, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/3", token, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("override still present: (%#x, %#x)", allow, deny) + } + if inv.invalidateAllN != 1 { + t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN) + } + if len(hub.visibilityRefreshes) != 1 { + t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes)) + } + + // Deleting again is idempotent. + w = doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/3", token, nil) + if w.Code != http.StatusNoContent { + t.Errorf("second delete status = %d, want 204", w.Code) + } +} diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 8b287829..40d1c733 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -402,6 +402,7 @@ func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {} func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {} func (m *mockHubWB) BroadcastMemberBan(userID int64) {} func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {} +func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {} func (m *mockHubWB) ClientCount() int { return 0 } // TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index a81aadfa..23145334 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -277,6 +277,7 @@ const I={ voice:'', megaphone:'', logs:'', + lock:'', }; /* ═══ State ═══ */ @@ -527,7 +528,8 @@ async function renderChannels(){ html+=''+esc(type)+''; html+=''+esc(cat)+''; html+=''+(archived?'Yes':'No')+''; - html+='
'; + const lockBtn=type==='dm'?'':''; + html+='
'+lockBtn+'
'; }); html+=''; return html; @@ -560,6 +562,47 @@ async function confirmDeleteChannel(id){ try{await api('DELETE','/channels/'+id);closeModal();showToast('Channel deleted');renderContent()}catch(e){showToast(e.message,'error')} } +/* ═══ Channel Access (private channels) ═══ */ +const DENY_PRIVATE=0x202; /* READ_MESSAGES | CONNECT_VOICE */ +const ADMIN_BIT=0x40000000; + +async function openChannelPermsModal(id,name){ + let data; + try{data=await api('GET','/channels/'+id+'/permissions')}catch(e){showToast(e.message,'error');return} + const roles=data.roles||[]; + state.permChannelRoles=roles; + let rows=''; + roles.forEach(role=>{ + const isAdmin=(role.permissions&ADMIN_BIT)!==0; + const canAccess=isAdmin||((role.deny&0x2)===0); + rows+='
' + +''+esc(role.role_name)+'' + +(isAdmin + ?'always has access' + :'') + +'
'; + }); + openModal('' + +'' + +''); +} + +async function saveChannelPerms(id){ + const roles=state.permChannelRoles||[]; + try{ + for(const role of roles){ + if((role.permissions&ADMIN_BIT)!==0)continue; + const box=document.getElementById('permRole'+role.role_id); + if(!box)continue; + const wasHidden=(role.deny&0x2)!==0; + if(!box.checked)await api('PUT','/channels/'+id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE}); + else if(wasHidden)await api('DELETE','/channels/'+id+'/permissions/'+role.role_id); + } + closeModal();showToast('Channel access updated');renderContent(); + }catch(e){showToast(e.message,'error')} +} + /* ═══ Audit Log ═══ */ async function renderAudit(){ const offset=(state.auditPage-1)*PAGE_SIZE; diff --git a/Server/admin/types.go b/Server/admin/types.go index c41953aa..299b0a17 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -40,6 +40,10 @@ type HubBroadcaster interface { BroadcastChannelDelete(channelID int64) BroadcastMemberBan(userID int64) BroadcastMemberUpdate(userID int64, roleName string) + // RefreshChannelVisibility sends targeted channel_create/channel_delete + // messages after a channel permission override change so each connected + // client's sidebar reflects its new visibility without a reconnect. + RefreshChannelVisibility(ch *db.Channel) ClientCount() int } diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index 4daccf5c..eae17e32 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -174,6 +174,81 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve return result, nil } +// UpsertChannelOverride inserts or updates the allow/deny permission override +// for a role on a channel. +func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error { + _, err := d.sqlDB.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) + VALUES (?, ?, ?, ?) + ON CONFLICT(channel_id, role_id) + DO UPDATE SET allow = excluded.allow, deny = excluded.deny`, + channelID, roleID, allow, deny, + ) + if err != nil { + return fmt.Errorf("UpsertChannelOverride: %w", err) + } + return nil +} + +// DeleteChannelOverride removes the permission override for a role on a +// channel. Deleting a non-existent override is a no-op. +func (d *DB) DeleteChannelOverride(channelID, roleID int64) error { + _, err := d.sqlDB.Exec( + `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = ?`, + channelID, roleID, + ) + if err != nil { + return fmt.Errorf("DeleteChannelOverride: %w", err) + } + return nil +} + +// ChannelRoleOverride pairs a role with its (possibly zero) permission +// override on a specific channel. Permissions carries the role's base bits so +// callers can tell which roles bypass overrides via Administrator. +type ChannelRoleOverride struct { + RoleID int64 `json:"role_id"` + RoleName string `json:"role_name"` + Position int `json:"position"` + Permissions int64 `json:"permissions"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +// ListChannelRoleOverrides returns every role together with its override bits +// on the given channel (zero allow/deny when no override row exists), ordered +// by role position descending. +func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, error) { + rows, err := d.sqlDB.Query( + `SELECT r.id, r.name, r.position, r.permissions, + COALESCE(o.allow, 0), COALESCE(o.deny, 0) + FROM roles r + LEFT JOIN channel_overrides o ON o.role_id = r.id AND o.channel_id = ? + ORDER BY r.position DESC, r.id ASC`, + channelID, + ) + if err != nil { + return nil, fmt.Errorf("ListChannelRoleOverrides: %w", err) + } + defer rows.Close() //nolint:errcheck + + var result []ChannelRoleOverride + for rows.Next() { + var o ChannelRoleOverride + if scanErr := rows.Scan(&o.RoleID, &o.RoleName, &o.Position, &o.Permissions, &o.Allow, &o.Deny); scanErr != nil { + return nil, fmt.Errorf("ListChannelRoleOverrides scan: %w", scanErr) + } + result = append(result, o) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListChannelRoleOverrides rows: %w", rows.Err()) + } + if result == nil { + result = []ChannelRoleOverride{} + } + return result, nil +} + // ─── helpers ────────────────────────────────────────────────────────────────── // scanChannel scans a single channel row from *sql.Rows. diff --git a/Server/db/channel_queries_test.go b/Server/db/channel_queries_test.go index 3f2fcbca..eabd8d07 100644 --- a/Server/db/channel_queries_test.go +++ b/Server/db/channel_queries_test.go @@ -234,6 +234,96 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) { } } +// ─── Channel override write path ───────────────────────────────────────────── + +func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("private", "text", "", "", 0) + + if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + t.Fatalf("UpsertChannelOverride insert: %v", err) + } + allow, deny, err := database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0x202 { + t.Errorf("after insert: got (%#x, %#x), want (0, 0x202)", allow, deny) + } + + // Upsert again with different bits — must update, not duplicate. + if err := database.UpsertChannelOverride(chID, 4, 0x2, 0x200); err != nil { + t.Fatalf("UpsertChannelOverride update: %v", err) + } + allow, deny, err = database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0x2 || deny != 0x200 { + t.Errorf("after update: got (%#x, %#x), want (0x2, 0x200)", allow, deny) + } +} + +func TestDeleteChannelOverride(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("private2", "text", "", "", 0) + + if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + if err := database.DeleteChannelOverride(chID, 4); err != nil { + t.Fatalf("DeleteChannelOverride: %v", err) + } + allow, deny, err := database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("after delete: got (%#x, %#x), want (0, 0)", allow, deny) + } + + // Deleting again is a no-op. + if err := database.DeleteChannelOverride(chID, 4); err != nil { + t.Errorf("DeleteChannelOverride non-existent should not error: %v", err) + } +} + +func TestListChannelRoleOverrides(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("private3", "text", "", "", 0) + + if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + overrides, err := database.ListChannelRoleOverrides(chID) + if err != nil { + t.Fatalf("ListChannelRoleOverrides: %v", err) + } + // All four seeded roles must be present, position descending (Owner first). + if len(overrides) != 4 { + t.Fatalf("expected 4 roles, got %d", len(overrides)) + } + if overrides[0].RoleID != 1 || overrides[0].RoleName != "Owner" { + t.Errorf("first role = (%d, %q), want (1, Owner)", overrides[0].RoleID, overrides[0].RoleName) + } + for _, o := range overrides { + switch o.RoleID { + case 4: + if o.Deny != 0x202 || o.Allow != 0 { + t.Errorf("member override = (%#x, %#x), want (0, 0x202)", o.Allow, o.Deny) + } + default: + if o.Allow != 0 || o.Deny != 0 { + t.Errorf("role %d override = (%#x, %#x), want zeros", o.RoleID, o.Allow, o.Deny) + } + } + if o.Permissions == 0 { + t.Errorf("role %d permissions should be non-zero", o.RoleID) + } + } +} + // ─── SetChannelSlowMode ───────────────────────────────────────────────────── func TestSetChannelSlowMode(t *testing.T) { diff --git a/Server/permissions/permissions.go b/Server/permissions/permissions.go index a74bb721..1068d532 100644 --- a/Server/permissions/permissions.go +++ b/Server/permissions/permissions.go @@ -26,6 +26,13 @@ const ( Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks ) +// AllPerms is the union of every defined permission bit. Use it to mask +// externally supplied permission values so unknown bits are dropped. +const AllPerms = SendMessages | ReadMessages | AttachFiles | AddReactions | + ConnectVoice | SpeakVoice | UseVideo | ShareScreen | + ManageMessages | ManageChannels | KickMembers | BanMembers | MuteMembers | + ManageRoles | ManageServer | ManageInvites | ViewAuditLog | Administrator + // ─── Role ID constants (default roles inserted on first run) ───────────────── const ( diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 43453778..ca46e826 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -509,6 +509,69 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) { h.BroadcastToAll(buildChannelDelete(channelID)) } +// RefreshChannelVisibility re-evaluates which connected clients may see ch +// after a channel_overrides change and sends targeted channel_create / +// channel_delete messages so sidebars converge without a reconnect. Clients +// that lose visibility are also unsubscribed from the channel topic and have +// their focused channel cleared so live messages stop flowing. +// +// The sends deliberately bypass the sequenced broadcast/replay path: a +// replayed channel_delete would be filtered by the allowed-channel set +// computed at replay time, which after an override change is exactly the +// inverse of the intended audience. Clients tolerate seq-less messages. +func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { + if ch == nil { + return + } + + h.mu.RLock() + clients := make([]*Client, 0, len(h.clients)) + for _, c := range h.clients { + clients = append(clients, c) + } + h.mu.RUnlock() + + // Visibility is a function of the role, so resolve each role once. + visibleByRole := make(map[int64]bool) + roleVisible := func(roleID int64) bool { + if v, ok := visibleByRole[roleID]; ok { + return v + } + visible := false + role, err := h.db.GetRoleByID(roleID) + if err == nil && role != nil { + if permissions.HasAdmin(role.Permissions) { + visible = true + } else { + allow, deny, permErr := h.db.GetChannelPermissions(ch.ID, roleID) + // Fail closed: an error hides the channel rather than leaking it. + visible = permErr == nil && + permissions.EffectivePerms(role.Permissions, allow, deny)&permissions.ReadMessages != 0 + } + } + visibleByRole[roleID] = visible + return visible + } + + for _, c := range clients { + if c.user == nil { + continue + } + if roleVisible(c.user.RoleID) { + // Idempotent add on the client; also refreshes channel metadata. + c.sendMsg(buildChannelCreate(ch)) + continue + } + c.sendMsg(buildChannelDelete(ch.ID)) + h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID)) + c.mu.Lock() + if c.channelID == ch.ID { + c.channelID = 0 + } + c.mu.Unlock() + } +} + // BroadcastMemberBan sends a member_ban message to all connected clients // and immediately disconnects the banned user's WebSocket connection (BUG-113). func (h *Hub) BroadcastMemberBan(userID int64) { diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index ecbbe1aa..ac56323e 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -860,6 +860,100 @@ func TestHub_VoiceSessionCount(t *testing.T) { } } +// ─── RefreshChannelVisibility ───────────────────────────────────────────────── + +// drainForMsgType reads messages from send until one with the given type +// arrives or the timeout expires. Returns the decoded payload-bearing message. +func drainForMsgType(t *testing.T, send chan []byte, msgType string) map[string]any { + t.Helper() + deadline := time.After(500 * time.Millisecond) + for { + select { + case raw := <-send: + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + if msg["type"] == msgType { + return msg + } + case <-deadline: + t.Fatalf("timed out waiting for %q message", msgType) + return nil + } + } +} + +// assertNoMsgType asserts that no message of the given type is buffered. +func assertNoMsgType(t *testing.T, send chan []byte, msgType string) { + t.Helper() + for { + select { + case raw := <-send: + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + if msg["type"] == msgType { + t.Fatalf("unexpected %q message", msgType) + } + default: + return + } + } +} + +func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "secret-room") + ch, err := database.GetChannel(chID) + if err != nil || ch == nil { + t.Fatalf("GetChannel: %v", err) + } + + owner := seedOwnerUser(t, database, "vis-owner") + memberID := seedTestUser(t, database, "vis-member") + member, err := database.GetUserByID(memberID) + if err != nil || member == nil { + t.Fatalf("GetUserByID: %v", err) + } + + ownerSend := make(chan []byte, 16) + memberSend := make(chan []byte, 16) + ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend) + memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend) + hub.Register(ownerClient) + hub.Register(memberClient) + time.Sleep(30 * time.Millisecond) + + // Hide the channel from the Member role (deny ReadMessages). + if _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, + chID, + ); err != nil { + t.Fatalf("insert override: %v", err) + } + + hub.RefreshChannelVisibility(ch) + + // Member loses the channel; owner (admin bit) keeps it. + drainForMsgType(t, memberSend, "channel_delete") + drainForMsgType(t, ownerSend, "channel_create") + + // Restore visibility — the member gets the channel back. + if _, err := database.Exec( + `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID, + ); err != nil { + t.Fatalf("delete override: %v", err) + } + hub.RefreshChannelVisibility(ch) + drainForMsgType(t, memberSend, "channel_create") + assertNoMsgType(t, memberSend, "channel_delete") +} + // hubTestSchema is the minimal schema needed for hub tests. var hubTestSchema = []byte(` CREATE TABLE IF NOT EXISTS roles (