feat(phase2): bulk delete + audit log; update roadmap/parity docs
This commit is contained in:
+15
-15
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
## 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 |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Direct messages | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Markdown support | ⚠️ basic | ✅ full | ✅ full | ❌ basic | ✅ full |
|
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||||
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Threads | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Threads | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Forum channels | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Forum channels | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Message search | ❌ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||||
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Rich embeds / link unfurling | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||||
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
@@ -80,17 +80,17 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state.
|
|||||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||||
| Per-channel permission overrides | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel permission overrides | 🔄 | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Role colors | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### dumpsterChat Permissions (current)
|
### 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 |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
| Kick | ⚠️ permission exists, UI? | ✅ | ✅ | ✅ | ✅ |
|
| Kick | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Ban | ⚠️ permission exists, UI? | ✅ | ✅ | ✅ | ✅ |
|
| Ban | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Mute (voice/text) | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Mute (voice/text) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Timeout | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Timeout | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Slowmode | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Slowmode | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| AutoMod | ❌ | ✅ | ✅ Flow Bots | ❌ | 🔄 |
|
| AutoMod | ❌ | ✅ | ✅ Flow Bots | ❌ | 🔄 |
|
||||||
| Audit log | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Audit log | 🔄 | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Message delete bulk | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Message delete bulk | 🔄 | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Member screening / applications | ❌ | ❌ | ✅ recruitment | ❌ | ❌ |
|
| Member screening / applications | ❌ | ❌ | ✅ recruitment | ❌ | ❌ |
|
||||||
| Reports | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Reports | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
|
|||||||
+28
-47
@@ -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.
|
> Must-have features that every Guilded user expects. Low risk, high impact.
|
||||||
|
|
||||||
### 1.1 Direct Messages
|
| # | Feature | Status |
|
||||||
- **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`.
|
| 1.1 | Direct Messages | ✅ Backend + frontend live |
|
||||||
- **WS events:** `MESSAGE_CREATE` already works; just scope to conversation members.
|
| 1.2 | Full Markdown Rendering | ✅ `react-markdown` + `remark-gfm` |
|
||||||
- **Frontend:** New `ConversationsList` component in a sidebar panel (switch between servers view and DMs view). New `DMChatArea` or reuse `ChatArea` with a conversation context.
|
| 1.3 | Rich Embeds / Link Unfurling | ✅ `internal/embed`, `embeds` table |
|
||||||
- **Estimated:** 4–6 files backend, 3–4 files frontend.
|
| 1.4 | Message Search | ✅ PostgreSQL full-text search |
|
||||||
|
| 1.5 | Ban / Kick / Mute UI | ✅ `internal/moderation`, `MemberContextMenu` |
|
||||||
### 1.2 Full Markdown Rendering
|
| 1.6 | Slowmode | ✅ DB column + message handler + UI |
|
||||||
- **Frontend:** Install `react-markdown` + `remark-gfm` (tables, strikethrough, task lists, autolinks). Replace the plain text renderer in `ChatArea` with `<ReactMarkdown>`.
|
|
||||||
- **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 `<title>`, `<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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Moderation & Permissions (2–3 weeks)
|
## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS
|
||||||
|
|
||||||
> Unlocks serious server organization. Guilded's strength was granular permissions.
|
> 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
|
### 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.
|
- **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}`.
|
- **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.
|
- **Estimated:** 3 files backend, 2 files frontend.
|
||||||
|
|
||||||
### 2.2 Expanded Permission Flags
|
### 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`.
|
- **Status:** ✅ Bits already defined and seeding default permissions.
|
||||||
- Update permission checker to gate the corresponding handlers.
|
- **Remaining:** Gate the corresponding handlers (webhooks, file upload, external emojis, mention everyone, voice mute/deaf/move).
|
||||||
- **Estimated:** 2 files backend.
|
- **Estimated:** 1 file backend.
|
||||||
|
|
||||||
### 2.3 Role Colors & Hierarchy
|
### 2.3 Role Colors & Hierarchy
|
||||||
- **Backend:** Add `color` (hex string) and `position` (integer) columns to `roles`. Permission resolution uses highest-position role's color.
|
- **Status:** ✅ `color` and `position` columns already exist on `roles`.
|
||||||
- **Frontend:** Role editor with color picker. Member names shown in their highest role color in chat and member list.
|
- **Remaining:** Render member names in highest-position role color in chat and member list.
|
||||||
- **Estimated:** 2 files backend, 2 files frontend.
|
- **Estimated:** 2 files frontend.
|
||||||
|
|
||||||
### 2.4 Audit Log
|
### 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.
|
- **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.
|
||||||
|
|||||||
+33
-5
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs"
|
_ "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/auth"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
|
||||||
@@ -142,22 +143,49 @@ func main() {
|
|||||||
r.Get("/", serverHandler.List)
|
r.Get("/", serverHandler.List)
|
||||||
|
|
||||||
modHandler := moderation.NewHandler(database.DB, hub)
|
modHandler := moderation.NewHandler(database.DB, hub)
|
||||||
|
auditLogger := audit.NewLogger(database.DB)
|
||||||
|
auditHandler := audit.NewHandler(database.DB)
|
||||||
|
|
||||||
r.Route("/{serverID}", func(r chi.Router) {
|
r.Route("/{serverID}", func(r chi.Router) {
|
||||||
r.Get("/", serverHandler.Get)
|
r.Get("/", serverHandler.Get)
|
||||||
r.Patch("/", serverHandler.Update)
|
r.Patch("/", serverHandler.Update)
|
||||||
r.Delete("/", serverHandler.Delete)
|
r.Delete("/", serverHandler.Delete)
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.MANAGE_SERVER)).Get("/audit-log", auditHandler.List)
|
||||||
|
|
||||||
// Server members
|
// Server members
|
||||||
memberHandler.RegisterRoutes(r)
|
memberHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
// Moderation
|
// Moderation
|
||||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", modHandler.Kick)
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", modHandler.Ban)
|
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)).Get("/bans", modHandler.ListBans)
|
||||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", modHandler.Unban)
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", modHandler.Mute)
|
modHandler.Unban(w, r)
|
||||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", modHandler.Unmute)
|
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)
|
// Channels (nested under servers)
|
||||||
r.Route("/channels", func(r chi.Router) {
|
r.Route("/channels", func(r chi.Router) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -284,6 +284,38 @@ CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id);
|
|||||||
-- Channel slowmode
|
-- Channel slowmode
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0;
|
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
|
-- Message full-text search
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
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);
|
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -46,10 +48,121 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
r.Get("/search", h.Search)
|
r.Get("/search", h.Search)
|
||||||
|
r.Post("/bulk-delete", h.BulkDelete)
|
||||||
r.Patch("/{messageID}", h.Update)
|
r.Patch("/{messageID}", h.Update)
|
||||||
r.Delete("/{messageID}", h.Delete)
|
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 {
|
type embedResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
|||||||
@@ -114,9 +114,18 @@ export function ChatArea() {
|
|||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(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
|
const channels = activeServerId
|
||||||
? channelsByServer[activeServerId] || []
|
? channelsByServer[activeServerId] || []
|
||||||
: [];
|
: [];
|
||||||
@@ -241,13 +250,22 @@ export function ChatArea() {
|
|||||||
: "[NO CHANNEL SELECTED]"}
|
: "[NO CHANNEL SELECTED]"}
|
||||||
</span>
|
</span>
|
||||||
{activeChannelId && (
|
{activeChannelId && (
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowSearch((prev) => !prev)}
|
<button
|
||||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
onClick={() => setSelectMode((prev) => !prev)}
|
||||||
title="Search messages"
|
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||||
>
|
title="Toggle bulk delete selection"
|
||||||
[SEARCH]
|
>
|
||||||
</button>
|
[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>
|
</div>
|
||||||
{showSearch && activeChannelId && activeChannel && (
|
{showSearch && activeChannelId && activeChannel && (
|
||||||
@@ -257,13 +275,50 @@ export function ChatArea() {
|
|||||||
onClose={() => setShowSearch(false)}
|
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">
|
<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 && <p className="text-gb-fg-f">[loading...]</p>}
|
||||||
{!isLoading && messages.length === 0 && (
|
{!isLoading && messages.length === 0 && (
|
||||||
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => (
|
{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">
|
<span className="text-gb-fg-f">
|
||||||
[{formatTime(message.created_at)}]
|
[{formatTime(message.created_at)}]
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ChannelList } from './ChannelList.tsx';
|
|||||||
import { ConversationList } from './ConversationList.tsx';
|
import { ConversationList } from './ConversationList.tsx';
|
||||||
import { MemberList } from './MemberList.tsx';
|
import { MemberList } from './MemberList.tsx';
|
||||||
import { VoicePanel } from './VoicePanel.tsx';
|
import { VoicePanel } from './VoicePanel.tsx';
|
||||||
|
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||||
function statusColor(status: UserStatus): string {
|
function statusColor(status: UserStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -38,6 +39,8 @@ export function Layout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
const [dmMode, setDmMode] = useState(false);
|
const [dmMode, setDmMode] = useState(false);
|
||||||
|
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||||
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMe();
|
fetchMe();
|
||||||
wsConnect();
|
wsConnect();
|
||||||
@@ -101,6 +104,14 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
<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">
|
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
||||||
[LOGOUT]
|
[LOGOUT]
|
||||||
</button>
|
</button>
|
||||||
@@ -146,6 +157,9 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
{!dmMode && <MemberList />}
|
{!dmMode && <MemberList />}
|
||||||
</div>
|
</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">
|
<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>TERM v1.0</span>
|
||||||
<span>{new Date().toISOString().slice(0, 10)}</span>
|
<span>{new Date().toISOString().slice(0, 10)}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ export interface SearchResultMessage extends Message {
|
|||||||
channel_name?: string;
|
channel_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageState {
|
export interface MessageState {
|
||||||
messagesByChannel: Record<string, Message[]>;
|
messagesByChannel: Record<string, Message[]>;
|
||||||
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||||
|
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
||||||
@@ -38,11 +39,15 @@ interface MessageState {
|
|||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
updateMessage: (message: Message) => void;
|
updateMessage: (message: Message) => void;
|
||||||
removeMessage: (channelId: string, messageId: string) => 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) => ({
|
export const useMessageStore = create<MessageState>((set) => ({
|
||||||
messagesByChannel: {},
|
messagesByChannel: {},
|
||||||
searchResultsByChannel: {},
|
searchResultsByChannel: {},
|
||||||
|
selectedMessageIds: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
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 };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
Reference in New Issue
Block a user