mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
319 lines
9.8 KiB
Go
319 lines
9.8 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ListChannels returns all channels ordered by position.
|
|
func (d *DB) ListChannels() ([]Channel, error) {
|
|
rows, err := d.sqlDB.Query(
|
|
`SELECT id, name, type, COALESCE(category,''), COALESCE(topic,''),
|
|
position, slow_mode, archived, created_at,
|
|
COALESCE(voice_max_users, 0),
|
|
voice_quality,
|
|
mixing_threshold,
|
|
COALESCE(voice_max_video, 0)
|
|
FROM channels ORDER BY position ASC, id ASC`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ListChannels: %w", err)
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
var channels []Channel
|
|
for rows.Next() {
|
|
ch, scanErr := scanChannel(rows)
|
|
if scanErr != nil {
|
|
return nil, fmt.Errorf("ListChannels scan: %w", scanErr)
|
|
}
|
|
channels = append(channels, ch)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, fmt.Errorf("ListChannels rows: %w", rows.Err())
|
|
}
|
|
if channels == nil {
|
|
channels = []Channel{}
|
|
}
|
|
return channels, nil
|
|
}
|
|
|
|
// GetChannel returns the channel with the given id, or nil if not found.
|
|
func (d *DB) GetChannel(id int64) (*Channel, error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT id, name, type, COALESCE(category,''), COALESCE(topic,''),
|
|
position, slow_mode, archived, created_at,
|
|
COALESCE(voice_max_users, 0),
|
|
voice_quality,
|
|
mixing_threshold,
|
|
COALESCE(voice_max_video, 0)
|
|
FROM channels WHERE id = ?`,
|
|
id,
|
|
)
|
|
ch := &Channel{}
|
|
var archived int
|
|
err := row.Scan(
|
|
&ch.ID, &ch.Name, &ch.Type, &ch.Category, &ch.Topic,
|
|
&ch.Position, &ch.SlowMode, &archived, &ch.CreatedAt,
|
|
&ch.VoiceMaxUsers, &ch.VoiceQuality, &ch.MixingThreshold, &ch.VoiceMaxVideo,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetChannel: %w", err)
|
|
}
|
|
ch.Archived = archived != 0
|
|
return ch, nil
|
|
}
|
|
|
|
// CreateChannel inserts a new channel and returns the assigned ID.
|
|
func (d *DB) CreateChannel(name, chanType, category, topic string, position int) (int64, error) {
|
|
res, err := d.sqlDB.Exec(
|
|
`INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?)`,
|
|
name, chanType, nullableString(category), nullableString(topic), position,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateChannel: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// UpdateChannel modifies name, topic, and slow_mode for the given channel.
|
|
func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error {
|
|
_, err := d.sqlDB.Exec(
|
|
`UPDATE channels SET name = ?, topic = ?, slow_mode = ? WHERE id = ?`,
|
|
name, nullableString(topic), slowMode, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("UpdateChannel: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetChannelSlowMode updates only the slow_mode field for the given channel.
|
|
func (d *DB) SetChannelSlowMode(id int64, slowMode int) error {
|
|
_, err := d.sqlDB.Exec(
|
|
`UPDATE channels SET slow_mode = ? WHERE id = ?`,
|
|
slowMode, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("SetChannelSlowMode: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetChannelVoiceMaxUsers updates the voice_max_users field for the given channel.
|
|
func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error {
|
|
_, err := d.sqlDB.Exec(`UPDATE channels SET voice_max_users = ? WHERE id = ?`, maxUsers, id)
|
|
if err != nil {
|
|
return fmt.Errorf("SetChannelVoiceMaxUsers: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteChannel removes the channel row (cascades to messages, overrides, etc.).
|
|
func (d *DB) DeleteChannel(id int64) error {
|
|
_, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteChannel: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetChannelPermissions returns the allow/deny override bits for a role on a
|
|
// channel. Returns (0, 0, nil) when no override exists.
|
|
func (d *DB) GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT allow, deny FROM channel_overrides WHERE channel_id = ? AND role_id = ?`,
|
|
channelID, roleID,
|
|
)
|
|
scanErr := row.Scan(&allow, &deny)
|
|
if errors.Is(scanErr, sql.ErrNoRows) {
|
|
return 0, 0, nil
|
|
}
|
|
if scanErr != nil {
|
|
return 0, 0, fmt.Errorf("GetChannelPermissions: %w", scanErr)
|
|
}
|
|
return allow, deny, nil
|
|
}
|
|
|
|
// ChannelOverride holds the allow/deny permission bits for a single channel.
|
|
type ChannelOverride struct {
|
|
Allow int64
|
|
Deny int64
|
|
}
|
|
|
|
// GetAllChannelPermissionsForRole returns all channel permission overrides for
|
|
// a role in a single query, keyed by channel ID. Eliminates N+1 queries when
|
|
// filtering channels by permission.
|
|
func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOverride, error) {
|
|
rows, err := d.sqlDB.Query(
|
|
`SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = ?`,
|
|
roleID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetAllChannelPermissionsForRole: %w", err)
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
result := make(map[int64]ChannelOverride)
|
|
for rows.Next() {
|
|
var chID int64
|
|
var o ChannelOverride
|
|
if scanErr := rows.Scan(&chID, &o.Allow, &o.Deny); scanErr != nil {
|
|
return nil, fmt.Errorf("GetAllChannelPermissionsForRole scan: %w", scanErr)
|
|
}
|
|
result[chID] = o
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, fmt.Errorf("GetAllChannelPermissionsForRole rows: %w", rows.Err())
|
|
}
|
|
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.
|
|
// The query must select the 13 columns: id, name, type, category, topic,
|
|
// position, slow_mode, archived, created_at, voice_max_users,
|
|
// voice_quality, mixing_threshold, voice_max_video.
|
|
func scanChannel(rows *sql.Rows) (Channel, error) {
|
|
var ch Channel
|
|
var archived int
|
|
err := rows.Scan(
|
|
&ch.ID, &ch.Name, &ch.Type, &ch.Category, &ch.Topic,
|
|
&ch.Position, &ch.SlowMode, &archived, &ch.CreatedAt,
|
|
&ch.VoiceMaxUsers, &ch.VoiceQuality, &ch.MixingThreshold, &ch.VoiceMaxVideo,
|
|
)
|
|
if err != nil {
|
|
return Channel{}, err
|
|
}
|
|
ch.Archived = archived != 0
|
|
return ch, nil
|
|
}
|
|
|
|
// nullableString returns nil when s is empty, otherwise a pointer to s.
|
|
// Used so empty strings are stored as NULL in optional TEXT columns.
|
|
func nullableString(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// GetChannelTypes returns a map of channel ID → type string for the given IDs
|
|
// in a single query, avoiding N+1 lookups.
|
|
func (d *DB) GetChannelTypes(ids []int64) (map[int64]string, error) {
|
|
if len(ids) == 0 {
|
|
return map[int64]string{}, nil
|
|
}
|
|
|
|
// Build placeholders and args.
|
|
placeholders := make([]string, len(ids))
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
|
|
query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input
|
|
`SELECT id, type FROM channels WHERE id IN (%s)`,
|
|
strings.Join(placeholders, ","),
|
|
)
|
|
|
|
rows, err := d.sqlDB.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetChannelTypes query: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
result := make(map[int64]string, len(ids))
|
|
for rows.Next() {
|
|
var id int64
|
|
var chType string
|
|
if err := rows.Scan(&id, &chType); err != nil {
|
|
return nil, fmt.Errorf("GetChannelTypes scan: %w", err)
|
|
}
|
|
result[id] = chType
|
|
}
|
|
return result, rows.Err()
|
|
}
|