From d7c84647e5292a1ad1ecbf2a4d5381d742b4b256 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 10:20:57 -0400 Subject: [PATCH] feat(phase2): bulk delete + audit log; update roadmap/parity docs --- FEATURE_PARITY.md | 30 ++--- GUILDED_ROADMAP.md | 75 +++++------- cmd/server/main.go | 38 +++++- internal/audit/audit.go | 56 +++++++++ internal/audit/handler.go | 125 ++++++++++++++++++++ internal/db/db.go | 32 +++++ internal/message/handlers.go | 113 ++++++++++++++++++ web/src/components/ChatArea.tsx | 71 ++++++++++-- web/src/components/Layout.tsx | 14 +++ web/src/components/ServerSettingsModal.tsx | 129 +++++++++++++++++++++ web/src/stores/message.ts | 52 ++++++++- web/tsconfig.app.tsbuildinfo | 2 +- 12 files changed, 660 insertions(+), 77 deletions(-) create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/handler.go create mode 100644 web/src/components/ServerSettingsModal.tsx diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 7b39d64..e264eeb 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -2,7 +2,7 @@ ## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer -Compiled 2026-06-30. Guilded data reflects its pre-shutdown state. +Compiled 2026-06-30. Updated after Phase 1 completion. --- @@ -22,16 +22,16 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state. | Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer | |---------|:------------:|:-------:|:-------:|:-----------:|:------:| | Text channels | ✅ | ✅ | ✅ | ✅ | ✅ | -| Direct messages | ❌ | ✅ | ✅ | ❌ | ✅ | -| Markdown support | ⚠️ basic | ✅ full | ✅ full | ❌ basic | ✅ full | +| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ | +| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full | | Reactions | ✅ | ✅ | ✅ | ❌ | ✅ | | Replies | ✅ | ✅ | ✅ | ❌ | ✅ | | Threads | ❌ | ✅ | ✅ | ❌ | 🔄 | | Forum channels | ❌ | ✅ | ✅ | ❌ | 🔄 | | Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ | -| Message search | ❌ | ✅ full | ✅ | ❌ | ✅ Meilisearch | +| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch | | Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ | -| Rich embeds / link unfurling | ❌ | ✅ | ✅ | ❌ | ✅ | +| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ | | File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed | | GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ | | Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ | @@ -80,17 +80,17 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state. | Roles | ✅ | ✅ | ✅ | ✅ | ✅ | | Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ | | Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ | -| Per-channel permission overrides | ❌ | ✅ | ✅ | ✅ | ✅ | +| Per-channel permission overrides | 🔄 | ✅ | ✅ | ✅ | ✅ | | @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ | -| Role colors | ❌ | ✅ | ✅ | ❌ | ✅ | +| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ | | Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ | | Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ | ### dumpsterChat Permissions (current) -`VIEW_CHANNEL`, `SEND_MESSAGES`, `MANAGE_MESSAGES`, `KICK_MEMBERS`, `BAN_MEMBERS`, `MANAGE_SERVER`, `MANAGE_CHANNELS`, `ADMINISTRATOR`, `CONNECT_VOICE`, `SPEAK_VOICE`, `SHARE_SCREEN`. +`VIEW_CHANNEL`, `SEND_MESSAGES`, `MANAGE_MESSAGES`, `KICK_MEMBERS`, `BAN_MEMBERS`, `MANAGE_SERVER`, `MANAGE_CHANNELS`, `ADMINISTRATOR`, `CONNECT_VOICE`, `SPEAK_VOICE`, `SHARE_SCREEN`, `MUTE_MEMBERS`, `CREATE_INSTANT_INVITE`, `CHANGE_NICKNAME`, `MANAGE_NICKNAMES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `EMBED_LINKS`, `ATTACH_FILES`, `ADD_REACTIONS`, `USE_EXTERNAL_EMOJIS`, `MENTION_EVERYONE`. -**Missing compared to Discord/Guilded:** `CREATE_INSTANT_INVITE`, `CHANGE_NICKNAME`, `MANAGE_NICKNAMES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `EMBED_LINKS`, `ATTACH_FILES`, `ADD_REACTIONS`, `USE_EXTERNAL_EMOJIS`, `MENTION_EVERYONE`, `USE_VOICE_ACTIVITY`, `PRIORITY_SPEAKER`, `MUTE_MEMBERS`, `DEAFEN_MEMBERS`, `MOVE_MEMBERS`, `REQUEST_TO_SPEAK`, etc. +**Missing compared to Discord/Guilded:** `USE_VOICE_ACTIVITY`, `PRIORITY_SPEAKER`, `DEAFEN_MEMBERS`, `MOVE_MEMBERS`, `REQUEST_TO_SPEAK`, etc. --- @@ -98,14 +98,14 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state. | Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer | |---------|:------------:|:-------:|:-------:|:-----------:|:------:| -| Kick | ⚠️ permission exists, UI? | ✅ | ✅ | ✅ | ✅ | -| Ban | ⚠️ permission exists, UI? | ✅ | ✅ | ✅ | ✅ | -| Mute (voice/text) | ❌ | ✅ | ✅ | ✅ | ✅ | +| Kick | ✅ | ✅ | ✅ | ✅ | ✅ | +| Ban | ✅ | ✅ | ✅ | ✅ | ✅ | +| Mute (voice/text) | ✅ | ✅ | ✅ | ✅ | ✅ | | Timeout | ❌ | ✅ | ❌ | ❌ | ❌ | -| Slowmode | ❌ | ✅ | ✅ | ❌ | ✅ | +| Slowmode | ✅ | ✅ | ✅ | ❌ | ✅ | | AutoMod | ❌ | ✅ | ✅ Flow Bots | ❌ | 🔄 | -| Audit log | ❌ | ✅ | ✅ | ❌ | ✅ | -| Message delete bulk | ❌ | ✅ | ✅ | ❌ | ✅ | +| Audit log | 🔄 | ✅ | ✅ | ❌ | ✅ | +| Message delete bulk | 🔄 | ✅ | ✅ | ❌ | ✅ | | Member screening / applications | ❌ | ❌ | ✅ recruitment | ❌ | ❌ | | Reports | ❌ | ✅ | ✅ | ❌ | ✅ | diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index 6395842..ae4f26e 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -6,56 +6,37 @@ --- -## Phase 1 — Core Chat Polish (2–3 weeks) +## Phase 1 — Core Chat Polish ✅ COMPLETE > Must-have features that every Guilded user expects. Low risk, high impact. -### 1.1 Direct Messages -- **Backend:** New `conversations` table (id, type: DM/group_dm, created_at), `conversation_members` join table. New `conversation_messages` table or reuse existing `messages` with a nullable `channel_id` and a required `conversation_id`. -- **Route:** `POST /conversations` (create DM), `GET /conversations`, `POST /conversations/{id}/messages`, `GET /conversations/{id}/messages`. -- **WS events:** `MESSAGE_CREATE` already works; just scope to conversation members. -- **Frontend:** New `ConversationsList` component in a sidebar panel (switch between servers view and DMs view). New `DMChatArea` or reuse `ChatArea` with a conversation context. -- **Estimated:** 4–6 files backend, 3–4 files frontend. - -### 1.2 Full Markdown Rendering -- **Frontend:** Install `react-markdown` + `remark-gfm` (tables, strikethrough, task lists, autolinks). Replace the plain text renderer in `ChatArea` with ``. -- **Backend:** No changes (store raw markdown). -- **Estimated:** 1 file frontend. - -### 1.3 Rich Embeds / Link Unfurling -- **Backend:** On message create, scan for URLs. For each URL, fetch ``, `<description>`, `<og:image>` via `og:` meta tags. Store in `embeds` table (message_id FK, url, title, description, image_url, color). -- **Rate limit:** Max 5 embeds per message, async fetch with 2s timeout, cache for 24h. -- **Frontend:** Render embeds as cards below the message (dark card with image, title, description). -- **Estimated:** 2 files backend, 1 file frontend. - -### 1.4 Message Search -- **Backend:** Add PostgreSQL full-text search. `ALTER TABLE messages ADD COLUMN search_vector tsvector`. Create a GIN index. Add trigger to populate on insert/update. -- **Route:** `GET /channels/{channelID}/messages/search?q=...&author_id=...&before=...&after=...&limit=25`. -- **Frontend:** Search bar in channel header or a dedicated search panel. Highlight matching terms in results. -- **Estimated:** 2 files backend, 2 files frontend. - -### 1.5 Ban / Kick / Mute UI -- **Backend:** Already have `KICK_MEMBERS` and `BAN_MEMBERS` permissions. Add: - - `DELETE /servers/{serverID}/members/{userID}` (kick) - - `POST /servers/{serverID}/bans` (ban, with optional reason and delete_message_days) - - `DELETE /servers/{serverID}/bans/{userID}` (unban) - - `GET /servers/{serverID}/bans` (list bans) - - `bans` table (server_id, user_id, reason, banned_by, created_at) - - Mute: `server_mutes` table (server_id, user_id, muted_by, expires_at, reason). New permission `MUTE_MEMBERS`. Muted users cannot send messages or speak in voice. -- **Frontend:** Context menu on member list items (right-click or three-dot menu) with Kick/Ban/Mute options. Confirmation modals for each action. -- **Estimated:** 3 files backend, 2 files frontend. - -### 1.6 Slowmode -- **Backend:** Add `slowmode_seconds` column to `channels`. New permission `MANAGE_SLOWMODE`. In message Create handler, check if user's last message in this channel was within `slowmode_seconds`; if so, return 429 with retry-after. -- **Frontend:** Show cooldown timer on the message input when slowmode is active. Channel settings to configure slowmode (0/5/10/30/60/120/300 seconds). -- **Estimated:** 2 files backend, 2 files frontend. +| # | Feature | Status | +|---|---------|--------| +| 1.1 | Direct Messages | ✅ Backend + frontend live | +| 1.2 | Full Markdown Rendering | ✅ `react-markdown` + `remark-gfm` | +| 1.3 | Rich Embeds / Link Unfurling | ✅ `internal/embed`, `embeds` table | +| 1.4 | Message Search | ✅ PostgreSQL full-text search | +| 1.5 | Ban / Kick / Mute UI | ✅ `internal/moderation`, `MemberContextMenu` | +| 1.6 | Slowmode | ✅ DB column + message handler + UI | --- -## Phase 2 — Moderation & Permissions (2–3 weeks) +## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS > Unlocks serious server organization. Guilded's strength was granular permissions. +--- + +## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS + +| # | Feature | Status | +|---|---------|--------| +| 2.1 | Per-Channel Permission Overrides | Pending | +| 2.2 | Expanded Permission Flags | ✅ Already added in `internal/permissions/permissions.go` | +| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI pending | +| 2.4 | Audit Log | In progress | +| 2.5 | Bulk Message Delete | In progress | + ### 2.1 Per-Channel Permission Overrides - **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions. - **Route:** `PUT /channels/{channelID}/permissions/{targetType}/{targetID}`, `GET /channels/{channelID}/permissions`, `DELETE /channels/{channelID}/permissions/{targetType}/{targetID}`. @@ -63,14 +44,14 @@ - **Estimated:** 3 files backend, 2 files frontend. ### 2.2 Expanded Permission Flags -- Add: `CREATE_INSTANT_INVITE`, `CHANGE_NICKNAME`, `MANAGE_NICKNAMES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `EMBED_LINKS`, `ATTACH_FILES`, `ADD_REACTIONS`, `USE_EXTERNAL_EMOJIS`, `MENTION_EVERYONE`, `MUTE_MEMBERS`, `DEAFEN_MEMBERS`, `MOVE_MEMBERS`. -- Update permission checker to gate the corresponding handlers. -- **Estimated:** 2 files backend. +- **Status:** ✅ Bits already defined and seeding default permissions. +- **Remaining:** Gate the corresponding handlers (webhooks, file upload, external emojis, mention everyone, voice mute/deaf/move). +- **Estimated:** 1 file backend. ### 2.3 Role Colors & Hierarchy -- **Backend:** Add `color` (hex string) and `position` (integer) columns to `roles`. Permission resolution uses highest-position role's color. -- **Frontend:** Role editor with color picker. Member names shown in their highest role color in chat and member list. -- **Estimated:** 2 files backend, 2 files frontend. +- **Status:** ✅ `color` and `position` columns already exist on `roles`. +- **Remaining:** Render member names in highest-position role color in chat and member list. +- **Estimated:** 2 files frontend. ### 2.4 Audit Log - **Backend:** New `audit_log` table (server_id, user_id, action_type, target_type, target_id, reason, changes JSONB, created_at). Instrument all moderation actions (kick, ban, mute, role changes, channel CRUD, server settings changes) to write audit entries. diff --git a/cmd/server/main.go b/cmd/server/main.go index 60c08e1..5a3797e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "strings" _ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/audit" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel" @@ -142,22 +143,49 @@ func main() { r.Get("/", serverHandler.List) modHandler := moderation.NewHandler(database.DB, hub) + auditLogger := audit.NewLogger(database.DB) + auditHandler := audit.NewHandler(database.DB) r.Route("/{serverID}", func(r chi.Router) { r.Get("/", serverHandler.Get) r.Patch("/", serverHandler.Update) r.Delete("/", serverHandler.Delete) + // Audit log + r.With(middleware.RequirePermission(permissionsChecker, permissions.MANAGE_SERVER)).Get("/audit-log", auditHandler.List) + // Server members memberHandler.RegisterRoutes(r) // Moderation - r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", modHandler.Kick) - r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", modHandler.Ban) + r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", func(w http.ResponseWriter, r *http.Request) { + modHandler.Kick(w, r) + serverID := chi.URLParam(r, "serverID") + targetID := chi.URLParam(r, "userID") + userID, _ := middleware.UserIDFromContext(r.Context()) + _ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionKick, "user", targetID, "", nil) + }) + r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", func(w http.ResponseWriter, r *http.Request) { + modHandler.Ban(w, r) + }) r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans) - r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", modHandler.Unban) - r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", modHandler.Mute) - r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", modHandler.Unmute) + r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", func(w http.ResponseWriter, r *http.Request) { + modHandler.Unban(w, r) + serverID := chi.URLParam(r, "serverID") + targetID := chi.URLParam(r, "userID") + userID, _ := middleware.UserIDFromContext(r.Context()) + _ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnban, "user", targetID, "", nil) + }) + r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", func(w http.ResponseWriter, r *http.Request) { + modHandler.Mute(w, r) + }) + r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", func(w http.ResponseWriter, r *http.Request) { + modHandler.Unmute(w, r) + serverID := chi.URLParam(r, "serverID") + targetID := chi.URLParam(r, "userID") + userID, _ := middleware.UserIDFromContext(r.Context()) + _ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnmute, "user", targetID, "", nil) + }) // Channels (nested under servers) r.Route("/channels", func(r chi.Router) { diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..1293fca --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,56 @@ +package audit + +import ( + "context" + "database/sql" + "encoding/json" +) + +// Action type constants. +const ( + ActionKick = "KICK" + ActionBan = "BAN" + ActionUnban = "UNBAN" + ActionMute = "MUTE" + ActionUnmute = "UNMUTE" + ActionRoleCreate = "ROLE_CREATE" + ActionRoleUpdate = "ROLE_UPDATE" + ActionRoleDelete = "ROLE_DELETE" + ActionMemberRoles = "MEMBER_ROLES_UPDATE" + ActionChannelCreate = "CHANNEL_CREATE" + ActionChannelUpdate = "CHANNEL_UPDATE" + ActionChannelDelete = "CHANNEL_DELETE" + ActionServerUpdate = "SERVER_UPDATE" + ActionMessageDelete = "MESSAGE_DELETE" + ActionBulkDelete = "BULK_DELETE" +) + +// Logger writes audit log entries to the database. +type Logger struct { + db *sql.DB +} + +// NewLogger creates a new audit logger. +func NewLogger(db *sql.DB) *Logger { + return &Logger{db: db} +} + +// Log records a single audit entry. +func (l *Logger) Log(ctx context.Context, serverID, userID, actionType, targetType, targetID, reason string, changes map[string]interface{}) error { + if l == nil || l.db == nil { + return nil + } + var changesJSON []byte + var err error + if len(changes) > 0 { + changesJSON, err = json.Marshal(changes) + if err != nil { + return err + } + } + _, err = l.db.ExecContext(ctx, ` + INSERT INTO audit_log (server_id, user_id, action_type, target_type, target_id, reason, changes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, serverID, userID, actionType, targetType, targetID, reason, changesJSON) + return err +} diff --git a/internal/audit/handler.go b/internal/audit/handler.go new file mode 100644 index 0000000..0ab9085 --- /dev/null +++ b/internal/audit/handler.go @@ -0,0 +1,125 @@ +package audit + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "strconv" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +// Handler exposes the audit log read API. +type Handler struct { + db *sql.DB +} + +// NewHandler creates a new audit log handler. +func NewHandler(db *sql.DB) *Handler { + return &Handler{db: db} +} + +// Entry represents a single audit log entry. +type Entry struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + UserID *string `json:"user_id"` + Username *string `json:"username"` + ActionType string `json:"action_type"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Reason *string `json:"reason"` + Changes json.RawMessage `json:"changes"` + CreatedAt string `json:"created_at"` +} + +// List handles GET /servers/{serverID}/audit-log. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + // ponytail: membership check keeps it simple; MANAGE_SERVER permission is the real gate. + var isMember bool + err := h.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM members WHERE server_id = $1 AND user_id = $2)`, serverID, userID).Scan(&isMember) + if err != nil || !isMember { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + limitStr := r.URL.Query().Get("limit") + limit := 50 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + actionType := r.URL.Query().Get("action_type") + before := r.URL.Query().Get("before") + + args := []interface{}{serverID} + query := ` + SELECT a.id, a.server_id, a.user_id, u.username, a.action_type, a.target_type, a.target_id, a.reason, a.changes, a.created_at::text + FROM audit_log a + LEFT JOIN users u ON u.id = a.user_id + WHERE a.server_id = $1 + ` + if actionType != "" { + args = append(args, actionType) + query += ` AND a.action_type = $` + strconv.Itoa(len(args)) + } + if before != "" { + args = append(args, before) + query += ` AND a.created_at < (SELECT created_at FROM audit_log WHERE id = $` + strconv.Itoa(len(args)) + `)` + } + query += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + args = append(args, limit) + + rows, err := h.db.QueryContext(r.Context(), query, args...) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + entries := make([]Entry, 0) + for rows.Next() { + var e Entry + var userIDStr, username, targetType, targetID, reason sql.NullString + err := rows.Scan(&e.ID, &e.ServerID, &userIDStr, &username, &e.ActionType, &targetType, &targetID, &reason, &e.Changes, &e.CreatedAt) + if err != nil { + continue + } + if userIDStr.Valid { + e.UserID = &userIDStr.String + } + if username.Valid { + e.Username = &username.String + } + if targetType.Valid { + e.TargetType = &targetType.String + } + if targetID.Valid { + e.TargetID = &targetID.String + } + if reason.Valid { + e.Reason = &reason.String + } + entries = append(entries, e) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +// Cleanup removes audit log entries older than 90 days. +func (h *Handler) Cleanup(ctx context.Context) error { + _, err := h.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL '90 days'`) + return err +} diff --git a/internal/db/db.go b/internal/db/db.go index 49c6efc..14fdba1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -284,6 +284,38 @@ CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id); -- Channel slowmode ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0; +-- Audit log +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action_type VARCHAR(32) NOT NULL, + target_type VARCHAR(32), + target_id VARCHAR(64), + reason TEXT, + changes JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_server_created ON audit_log(server_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(server_id, action_type); + +-- Channel permission overrides +CREATE TABLE IF NOT EXISTS channel_overrides ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + target_type VARCHAR(8) NOT NULL CHECK (target_type IN ('role', 'user')), + target_id UUID NOT NULL, + allow_bitflags BIGINT NOT NULL DEFAULT 0, + deny_bitflags BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (channel_id, target_type, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id); + -- Message full-text search ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector; CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector); diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 51f2eee..8ff8b85 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strconv" @@ -14,6 +15,7 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "github.com/go-chi/chi/v5" ) @@ -46,10 +48,121 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.List) r.Post("/", h.Create) r.Get("/search", h.Search) + r.Post("/bulk-delete", h.BulkDelete) r.Patch("/{messageID}", h.Update) r.Delete("/{messageID}", h.Delete) } +type bulkDeleteRequest struct { + Messages []string `json:"messages"` +} + +type bulkDeleteResponse struct { + Deleted int `json:"deleted"` +} + +// BulkDelete deletes up to 100 messages in a channel. Requires MANAGE_MESSAGES +// and messages must be within the last 14 days. +func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, serverID, ok := h.requireChannelAccess(w, r, channelID) + if !ok { + return + } + + // ponytail: reuse existing auth structure; requireChannelAccess already verifies membership. + // Permission check uses the server's permission checker via the caller's middleware chain. + allowed, err := h.checkPermission(r.Context(), serverID, userID, permissions.MANAGE_MESSAGES) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + var req bulkDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if len(req.Messages) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: 0}) + return + } + if len(req.Messages) > 100 { + http.Error(w, `{"error":"too many messages (max 100)"}`, http.StatusBadRequest) + return + } + + // ponytail: simple IN query with 14-day guard and channel_id filter in DB. + placeholders := make([]string, len(req.Messages)) + args := make([]interface{}, 0, len(req.Messages)+1) + for i, id := range req.Messages { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args = append(args, id) + } + args = append(args, channelID) + query := fmt.Sprintf(` + DELETE FROM messages + WHERE id IN (%s) AND channel_id = $%d AND created_at > NOW() - INTERVAL '14 days' + `, strings.Join(placeholders, ","), len(args)) + res, err := h.db.ExecContext(r.Context(), query, args...) + if err != nil { + http.Error(w, `{"error":"failed to delete messages"}`, http.StatusInternalServerError) + return + } + deleted, _ := res.RowsAffected() + + if h.hub != nil { + for _, id := range req.Messages { + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageDelete, + Data: map[string]string{ + "id": id, + "channel_id": channelID, + }, + }) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: int(deleted)}) +} + +// checkPermission is a helper wrapper around a permission checker lookup. +func (h *Handler) checkPermission(ctx context.Context, serverID, userID string, perm int64) (bool, error) { + // ponytail: keep it minimal; caller already has serverID. If no checker is wired, fall back to true only for admins. + rows, err := h.db.QueryContext(ctx, ` + SELECT r.permissions FROM roles r + INNER JOIN member_roles mr ON mr.role_id = r.id + WHERE mr.user_id = $1 AND mr.server_id = $2 + `, userID, serverID) + if err != nil { + return false, err + } + defer rows.Close() + var effective int64 + for rows.Next() { + var p int64 + if err := rows.Scan(&p); err != nil { + return false, err + } + effective |= p + } + if err := rows.Err(); err != nil { + return false, err + } + var everyone int64 + _ = h.db.QueryRowContext(ctx, ` + SELECT permissions FROM roles WHERE server_id = $1 AND is_default = TRUE LIMIT 1 + `, serverID).Scan(&everyone) + effective |= everyone + return permissions.Has(effective, permissions.ADMINISTRATOR) || permissions.Has(effective, perm), nil +} + type embedResponse struct { ID string `json:"id"` URL string `json:"url"` diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index c58619d..f2921d9 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -114,9 +114,18 @@ export function ChatArea() { const [showSearch, setShowSearch] = useState(false); const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); + const [selectMode, setSelectMode] = useState(false); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null); + const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage); + const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages); + const bulkDeleteMessages = useMessageStore((s) => s.bulkDeleteMessages); + const selectedIds = useMessageStore((s) => + activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(), + ); + const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth + const channels = activeServerId ? channelsByServer[activeServerId] || [] : []; @@ -241,13 +250,22 @@ export function ChatArea() { : "[NO CHANNEL SELECTED]"} </span> {activeChannelId && ( - <button - onClick={() => setShowSearch((prev) => !prev)} - className="text-xs text-gb-fg-f hover:text-gb-orange font-mono" - title="Search messages" - > - [SEARCH] - </button> + <div className="flex items-center gap-2"> + <button + onClick={() => setSelectMode((prev) => !prev)} + className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`} + title="Toggle bulk delete selection" + > + [SELECT] + </button> + <button + onClick={() => setShowSearch((prev) => !prev)} + className="text-xs text-gb-fg-f hover:text-gb-orange font-mono" + title="Search messages" + > + [SEARCH] + </button> + </div> )} </div> {showSearch && activeChannelId && activeChannel && ( @@ -257,13 +275,50 @@ export function ChatArea() { onClose={() => setShowSearch(false)} /> )} + {selectMode && activeChannelId && ( + <div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t"> + <span>{selectedIds.size} selected</span> + <div className="flex items-center gap-2"> + <button + onClick={() => clearSelectedMessages(activeChannelId)} + className="text-gb-fg-f hover:text-gb-orange" + > + [CLEAR] + </button> + <button + onClick={async () => { + if (!activeChannelId || selectedIds.size === 0) return; + if (!confirm(`Delete ${selectedIds.size} messages?`)) return; + try { + await bulkDeleteMessages(activeChannelId, Array.from(selectedIds)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Bulk delete failed'); + } + }} + disabled={selectedIds.size === 0 || !canBulkDelete} + className="text-gb-red hover:text-gb-orange disabled:opacity-50" + > + [DELETE] + </button> + </div> + </div> + )} <div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm"> {isLoading && <p className="text-gb-fg-f">[loading...]</p>} {!isLoading && messages.length === 0 && ( <p className="text-gb-fg-f">[no messages in this channel]</p> )} {messages.map((message) => ( - <div key={message.id} className="break-words"> + <div + key={message.id} + onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)} + className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`} + > + {selectMode && activeChannelId && ( + <span className="text-gb-fg-f mr-2"> + {selectedIds.has(message.id) ? '[x]' : '[ ]'} + </span> + )} <span className="text-gb-fg-f"> [{formatTime(message.created_at)}] </span>{" "} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 26ab534..98d9c9a 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -8,6 +8,7 @@ import { ChannelList } from './ChannelList.tsx'; import { ConversationList } from './ConversationList.tsx'; import { MemberList } from './MemberList.tsx'; import { VoicePanel } from './VoicePanel.tsx'; +import { ServerSettingsModal } from './ServerSettingsModal.tsx'; const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline']; function statusColor(status: UserStatus): string { switch (status) { @@ -38,6 +39,8 @@ export function Layout() { const location = useLocation(); const [showStatusMenu, setShowStatusMenu] = useState(false); const [dmMode, setDmMode] = useState(false); + const [showServerSettings, setShowServerSettings] = useState(false); + const activeServerId = useServerStore((s) => s.activeServerId); useEffect(() => { fetchMe(); wsConnect(); @@ -101,6 +104,14 @@ export function Layout() { )} </div> <Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link> + {activeServerId && ( + <button + onClick={() => setShowServerSettings(true)} + className="terminal-button text-xs" + > + [SERVER SETTINGS] + </button> + )} <button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs"> [LOGOUT] </button> @@ -146,6 +157,9 @@ export function Layout() { </div> {!dmMode && <MemberList />} </div> + {showServerSettings && activeServerId && ( + <ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} /> + )} <div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s"> <span>TERM v1.0</span> <span>{new Date().toISOString().slice(0, 10)}</span> diff --git a/web/src/components/ServerSettingsModal.tsx b/web/src/components/ServerSettingsModal.tsx new file mode 100644 index 0000000..252bc04 --- /dev/null +++ b/web/src/components/ServerSettingsModal.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import { api } from '../lib/api.ts'; +import { useServerStore } from '../stores/server.ts'; + +interface ServerSettingsModalProps { + serverId: string; + onClose: () => void; +} + +interface AuditEntry { + id: string; + server_id: string; + user_id: string | null; + username: string | null; + action_type: string; + target_type: string | null; + target_id: string | null; + reason: string | null; + changes: unknown; + created_at: string; +} + +const ACTION_LABELS: Record<string, string> = { + KICK: 'kicked member', + BAN: 'banned member', + UNBAN: 'unbanned member', + MUTE: 'muted member', + UNMUTE: 'unmuted member', + ROLE_CREATE: 'created role', + ROLE_UPDATE: 'updated role', + ROLE_DELETE: 'deleted role', + MEMBER_ROLES_UPDATE: 'updated member roles', + CHANNEL_CREATE: 'created channel', + CHANNEL_UPDATE: 'updated channel', + CHANNEL_DELETE: 'deleted channel', + SERVER_UPDATE: 'updated server', + MESSAGE_DELETE: 'deleted message', + BULK_DELETE: 'bulk deleted messages', +}; + +export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) { + const [tab, setTab] = useState<'audit'>('audit'); + const [entries, setEntries] = useState<AuditEntry[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const servers = useServerStore((s) => s.servers); + const server = servers.find((s) => s.id === serverId); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + useEffect(() => { + if (tab !== 'audit') return; + setLoading(true); + api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`) + .then((data) => setEntries(Array.isArray(data) ? data : [])) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log')) + .finally(() => setLoading(false)); + }, [tab, serverId]); + + const formatTime = (iso: string) => { + const d = new Date(iso); + return d.toLocaleString(); + }; + + return ( + <div + className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" + onClick={onClose} + > + <div + className="bg-gb-bg border border-gb-bg-t w-[700px] max-h-[80vh] flex flex-col font-mono" + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t"> + <span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span> + <button onClick={onClose} className="text-xs text-gb-red">[x]</button> + </div> + <div className="flex border-b border-gb-bg-t"> + <button + onClick={() => setTab('audit')} + className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`} + > + [AUDIT LOG] + </button> + </div> + <div className="flex-1 overflow-y-auto p-4"> + {tab === 'audit' && ( + <> + {loading && <p className="text-gb-fg-f text-xs">[loading...]</p>} + {error && <p className="text-gb-red text-xs">ERR: {error}</p>} + {!loading && !error && entries.length === 0 && ( + <p className="text-gb-fg-f text-xs">[no audit log entries]</p> + )} + <div className="space-y-2"> + {entries.map((entry) => ( + <div key={entry.id} className="text-xs border border-gb-bg-t p-2 bg-gb-bg-s"> + <div className="flex items-center justify-between text-gb-fg-f"> + <span>{formatTime(entry.created_at)}</span> + <span className="text-gb-orange">{entry.action_type}</span> + </div> + <div className="text-gb-fg mt-1"> + <span className="text-gb-aqua">{entry.username ?? entry.user_id ?? 'system'}</span> + {' '}<span className="text-gb-fg-s">{ACTION_LABELS[entry.action_type] ?? entry.action_type}</span> + {entry.target_id && ( + <span className="text-gb-fg-f"> target:{entry.target_id}</span> + )} + </div> + {entry.reason && ( + <div className="text-gb-fg-f mt-1">reason: {entry.reason}</div> + )} + </div> + ))} + </div> + </> + )} + </div> + </div> + </div> + ); +} diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts index 6d29021..5d5a68c 100644 --- a/web/src/stores/message.ts +++ b/web/src/stores/message.ts @@ -27,9 +27,10 @@ export interface SearchResultMessage extends Message { channel_name?: string; } -interface MessageState { +export interface MessageState { messagesByChannel: Record<string, Message[]>; searchResultsByChannel: Record<string, SearchResultMessage[]>; + selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection isLoading: boolean; error: string | null; fetchMessages: (channelId: string, before?: string) => Promise<void>; @@ -38,11 +39,15 @@ interface MessageState { addMessage: (message: Message) => void; updateMessage: (message: Message) => void; removeMessage: (channelId: string, messageId: string) => void; + bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>; + toggleSelectedMessage: (channelId: string, messageId: string) => void; + clearSelectedMessages: (channelId: string) => void; } export const useMessageStore = create<MessageState>((set) => ({ messagesByChannel: {}, searchResultsByChannel: {}, + selectedMessageIds: {}, isLoading: false, error: null, @@ -140,4 +145,49 @@ export const useMessageStore = create<MessageState>((set) => ({ }, }; }), + + bulkDeleteMessages: async (channelId, messageIds) => { + const res = await api.post<{ deleted: number }>( + `/channels/${channelId}/messages/bulk-delete`, + { messages: messageIds }, + ); + set((state) => { + const list = state.messagesByChannel[channelId] || []; + const ids = new Set(messageIds); + const selected = { ...state.selectedMessageIds }; + delete selected[channelId]; + return { + messagesByChannel: { + ...state.messagesByChannel, + [channelId]: list.filter((m) => !ids.has(m.id)), + }, + selectedMessageIds: selected, + }; + }); + return res; + }, + + toggleSelectedMessage: (channelId, messageId) => + set((state) => { + const current = state.selectedMessageIds[channelId] || new Set<string>(); + const next = new Set(current); + if (next.has(messageId)) { + next.delete(messageId); + } else { + next.add(messageId); + } + return { + selectedMessageIds: { + ...state.selectedMessageIds, + [channelId]: next, + }, + }; + }), + + clearSelectedMessages: (channelId) => + set((state) => { + const next = { ...state.selectedMessageIds }; + delete next[channelId]; + return { selectedMessageIds: next }; + }), })); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 1d14379..7f839ce 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file