feat(phase2): bulk delete + audit log; update roadmap/parity docs

This commit is contained in:
2026-06-30 10:20:57 -04:00
parent d3b7f39b9e
commit d7c84647e5
12 changed files with 660 additions and 77 deletions
+15 -15
View File
@@ -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 | ❌ | ✅ | ✅ | ❌ | ✅ |
+28 -47
View File
@@ -6,56 +6,37 @@
---
## Phase 1 — Core Chat Polish (23 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:** 46 files backend, 34 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 `<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.
| # | 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 (23 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.
+33 -5
View File
@@ -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) {
+56
View File
@@ -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
}
+125
View File
@@ -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
}
+32
View File
@@ -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);
+113
View File
@@ -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"`
+63 -8
View File
@@ -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>{" "}
+14
View File
@@ -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>
+129
View File
@@ -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>
);
}
+51 -1
View File
@@ -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 };
}),
}));
+1 -1
View File
@@ -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"}