From 30e159cbdb36234da7c0db4795f32db574179d7e Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 09:26:03 -0400 Subject: [PATCH] sync: phase 1 backend + frontend from server --- ...06-29_120000-security-remediation-p0-p2.md | 1014 +++++++++++ cmd/server/main.go | 18 + internal/channel/handlers.go | 53 +- internal/db/db.go | 85 + internal/dm/handlers.go | 426 +++++ internal/embed/embed.go | 149 ++ internal/gateway/hub.go | 40 + internal/message/handlers.go | 215 ++- internal/moderation/handlers.go | 207 +++ internal/permissions/permissions.go | 37 +- web/package-lock.json | 1475 ++++++++++++++++- web/package.json | 2 + web/src/App.tsx | 3 + web/src/components/ChatArea.tsx | 142 +- web/src/components/ConversationList.tsx | 63 + web/src/components/CreateChannelModal.tsx | 26 +- web/src/components/DMChat.tsx | 121 ++ web/src/components/Layout.tsx | 46 +- web/src/components/MemberContextMenu.tsx | 138 ++ web/src/components/MemberList.tsx | 65 +- web/src/components/MessageSearch.tsx | 99 ++ web/src/components/NewConversationModal.tsx | 87 + web/src/components/ServerBar.tsx | 29 +- web/src/lib/api.ts | 21 +- web/src/lib/usePermissions.ts | 50 + web/src/stores/conversation.ts | 120 ++ web/src/stores/layout.ts | 11 + web/src/stores/message.ts | 39 +- web/src/stores/moderation.ts | 67 + web/src/stores/server.ts | 22 + web/tsconfig.app.tsbuildinfo | 2 +- 31 files changed, 4758 insertions(+), 114 deletions(-) create mode 100644 .hermes/plans/2026-06-29_120000-security-remediation-p0-p2.md create mode 100644 internal/dm/handlers.go create mode 100644 internal/embed/embed.go create mode 100644 internal/moderation/handlers.go create mode 100644 web/src/components/ConversationList.tsx create mode 100644 web/src/components/DMChat.tsx create mode 100644 web/src/components/MemberContextMenu.tsx create mode 100644 web/src/components/MessageSearch.tsx create mode 100644 web/src/components/NewConversationModal.tsx create mode 100644 web/src/lib/usePermissions.ts create mode 100644 web/src/stores/conversation.ts create mode 100644 web/src/stores/layout.ts create mode 100644 web/src/stores/moderation.ts diff --git a/.hermes/plans/2026-06-29_120000-security-remediation-p0-p2.md b/.hermes/plans/2026-06-29_120000-security-remediation-p0-p2.md new file mode 100644 index 0000000..6285794 --- /dev/null +++ b/.hermes/plans/2026-06-29_120000-security-remediation-p0-p2.md @@ -0,0 +1,1014 @@ +# dumpsterChat Security Remediation Plan (P0-P2) + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Fix all P0, P1, and P2 security findings from the 2026-06-29 audit. + +**Architecture:** Changes are confined to the Go backend (`internal/`, `cmd/`), with one frontend change (WS token transport). Each fix is independently deployable. The plan is ordered so that quick wins land first, and later tasks build on middleware infrastructure created by earlier ones. + +**Tech Stack:** Go 1.24+, chi/v5, gorilla/websocket, golang.org/x/time/rate + +**Project root:** `~/Projects/dumpsterChat/` + +--- + +## Findings Summary + +| # | Severity | Finding | Status | +|---|----------|---------|--------| +| 1 | P0 | WebSocket Origin Bypass | OPEN | +| 2 | P0 | Session Token in URL | OPEN | +| 3 | P0 | WebAuthn Cookie Missing Secure | OPEN | +| 4 | P0 | PostgreSQL sslmode=disable | OPEN | +| 5 | P1 | No Rate Limiting | OPEN | +| 6 | P1 | WS Broadcast to All Users (data leak) | OPEN | +| 7 | P1 | Webhook Tokens Stored Plaintext | OPEN | +| 8 | P1 | No Security Headers | OPEN | +| 9 | P2 | No CSRF Protection | OPEN | +| 10 | P2 | Channel CRUD Lacks Permission Checks | OPEN | +| 11 | P2 | Upload: No File Type/Size Validation | OPEN | +| 12 | P2 | File Serve Path Traversal | OPEN | + +--- + +## Dependency Graph + +```mermaid +graph TD + T1["#1 WS Origin Check"] --> T2["#2 WS Token Transport"] + T3["#3 WebAuthn Cookie Fix"] + T4["#4 DB sslmode"] + T5["#5 Rate Limiter Middleware"] + T6["#6 WS Broadcast Filtering"] --> T6a["#6a Hub server-membership map"] + T7["#7 Webhook Token Hashing"] + T8["#8 Security Headers Middleware"] + T9["#9 CSRF Middleware"] + T10["#10 Channel Permission Checks"] + T11["#11 Upload Validation"] + T12["#12 File Serve Path Sanitization"] + + T5 --> T9 + T8 --> T9 +``` + +--- + +## Phase 1: Quick P0 Wins (< 15 min each) + +### Task 1: WebSocket Origin Check + +**Objective:** Reject WebSocket connections from untrusted origins. + +**Files:** +- Modify: `internal/gateway/client.go:21-25` + +**Current code (line 21-25):** +```go +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} +``` + +**Replace with:** +```go +// NewWSUpgrader creates a websocket.Upgrader with origin checking. +// allowedOrigins is the set of trusted origins (e.g. ["https://chat.example.com"]). +func NewWSUpgrader(allowedOrigins []string) websocket.Upgrader { + originSet := make(map[string]struct{}, len(allowedOrigins)) + for _, o := range allowedOrigins { + originSet[o] = struct{}{} + } + return websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + _, ok := originSet[origin] + return ok + }, + } +} +``` + +**Then in `cmd/server/main.go`:** Before `gateway.ServeWS` is called, construct the upgrader with the allowed origins and pass it through. The simplest approach is to make `ServeWS` accept the upgrader as a parameter, or set it as a package-level var: + +```go +// In cmd/server/main.go, after config load: +origins := []string{"https://" + cfg.Host} +if cfg.Host == "localhost" { + origins = append(origins, "http://localhost:"+cfg.Port) +} +gateway.SetAllowedOrigins(origins) +``` + +Add to `internal/gateway/client.go`: +```go +// SetAllowedOrigins replaces the default upgrader with one that checks origins. +func SetAllowedOrigins(origins []string) { + upgrader = NewWSUpgrader(origins) +} +``` + +**Verify:** Build compiles. Manual test: connect from allowed origin succeeds, from `http://evil.com` gets 403. + +**Commit:** `fix(security): enforce WebSocket origin checking` + +--- + +### Task 2: WebAuthn Cookie Missing Secure Flag + +**Objective:** Add `Secure: true` to the WebAuthn login session cookie. + +**Files:** +- Modify: `internal/auth/webauthn.go:334-341` + +**Current code (line 334-341):** +```go +http.SetCookie(w, &http.Cookie{ + Name: "dumpster_session", + Value: token, + Path: "/", + HttpOnly: true, + MaxAge: 86400 * 30, + SameSite: http.SameSiteStrictMode, +}) +``` + +**Replace with (reuse the existing helper):** +```go +h.setSessionCookie(w, token) +``` + +Wait, `WebAuthnHandler` doesn't have access to `setSessionCookie` since that's on the regular `Handler`. Two options: + +**Option A (preferred):** Extract `setSessionCookie` into a shared function in the `auth` package: + +Add to `internal/auth/handlers.go` (or a new `internal/auth/cookie.go`): +```go +// SetSessionCookie writes the session cookie with secure defaults. +func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(duration.Seconds()), + }) +} +``` + +Then update `Handler.setSessionCookie` to call it, and update `webauthn.go:334` to call it too: +```go +SetSessionCookie(w, "dumpster_session", token, 30*24*time.Hour) +``` + +**Option B (quick):** Just add `Secure: true` to the existing cookie block. + +Go with Option A for DRY. + +**Verify:** Build compiles. Login via WebAuthn sets cookie with `Secure` flag. + +**Commit:** `fix(security): add Secure flag to WebAuthn session cookie` + +--- + +### Task 3: PostgreSQL sslmode Configurable + +**Objective:** Allow `sslmode` to be configured via env var, default to `require`. + +**Files:** +- Modify: `internal/config/config.go:94-96` + +**Current code:** +```go +func (c *Config) DatabaseDSN() string { + return "postgres://" + c.Database.User + ":***" + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=disable" +} +``` + +**Step 1:** Add `SSLMode` field to `DatabaseConfig`: +```go +type DatabaseConfig struct { + Host string + Port string + User string + Password string + Database string + SSLMode string +} +``` + +**Step 2:** In `Load()`, populate it: +```go +Database: DatabaseConfig{ + ... + SSLMode: getEnv("POSTGRES_SSLMODE", "require"), +}, +``` + +**Step 3:** Fix `DatabaseDSN()` to include the real password (it currently masks it with `***`) and use the configured sslmode: +```go +func (c *Config) DatabaseDSN() string { + return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode +} +``` + +Note: The current DSN uses `***` instead of the actual password. That's a separate bug (the DSN used for `sql.Open` must have the real password). Verify that `db.New(cfg)` calls `cfg.DatabaseDSN()` and it works. + +**Step 4:** Update `docker/compose.yml` to add `POSTGRES_SSLMODE: disable` for local dev: +```yaml +POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-disable} +``` + +**Verify:** `go build ./...` compiles. With no env var set, DSN contains `sslmode=require`. With `POSTGRES_SSLMODE=disable`, DSN contains `sslmode=disable`. + +**Commit:** `fix(security): make PostgreSQL sslmode configurable, default require` + +--- + +## Phase 2: P0 WS Token Transport (30-60 min) + +### Task 4: WebSocket Token Transport via Initial Message + +**Objective:** Remove session token from URL query parameter; authenticate via first WS message. + +This is the most complex change because it touches both backend and frontend. + +**Files:** +- Modify: `internal/gateway/client.go` (ServeWS function) +- Modify: `web/src/stores/ws.ts` (connect function) + +**Backend changes in `internal/gateway/client.go`:** + +Replace `ServeWS` (lines 123-157): + +```go +func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) { + // Don't authenticate here. Accept the upgrade first. + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("websocket upgrade failed", "error", err) + return + } + + // Wait for the auth message with a short deadline. + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + logger.Warn("ws auth: failed to read auth message", "error", err) + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`)) + conn.Close() + return + } + + var authMsg struct { + Token string `json:"token"` + } + if err := json.Unmarshal(raw, &authMsg); err != nil || authMsg.Token == "" { + logger.Warn("ws auth: invalid auth message") + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`)) + conn.Close() + return + } + + var userID string + err = db.QueryRowContext(context.Background(), + `SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`, + authMsg.Token, + ).Scan(&userID) + if err != nil { + logger.Warn("ws auth: invalid session", "error", err) + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`)) + conn.Close() + return + } + + // Auth succeeded. Reset read deadline. + conn.SetReadDeadline(time.Time{}) + + client := &Client{ + Hub: hub, + Conn: conn, + UserID: userID, + send: make(chan []byte, 256), + } + + hub.Register(client) + go client.writePump() + go client.readPump() +} +``` + +**Frontend changes in `web/src/stores/ws.ts`:** + +Replace the `connect` function (lines 67-73) to open a clean WS and send the auth message: + +```typescript +connect: (token: string) => { + const existing = get().socket; + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) { + return; + } + + const socket = new WebSocket(getWsHost() + '/ws'); + + // Authenticate after connection opens + socket.onopen = () => { + socket.send(JSON.stringify({ token })); + }; + + // ... rest of handlers stay the same, but move the onopen set({ connected: true }) + // into a handler for a server "authenticated" confirmation, or just set it after + // the send since the server will close the connection if auth fails. + + socket.onopen = () => { + socket.send(JSON.stringify({ token })); + set({ connected: true }); + reconnectDelay = 1000; + }; + + // ... rest unchanged +``` + +Actually, cleaner approach: the server should send an `{"type":"ready"}` message after auth succeeds, and the client waits for that before marking connected: + +**Server side (add after `hub.Register(client)`):** +```go +conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`)) +``` + +**Client side:** +```typescript +socket.onopen = () => { + socket.send(JSON.stringify({ token })); +}; + +// In onmessage, handle the 'ready' event first: +case 'ready': { + set({ connected: true }); + reconnectDelay = 1000; + return; +} +``` + +And remove the `onopen` setting of `connected: true`. + +**Verify:** +- Build Go backend: `go build ./cmd/server` +- Build frontend: `cd web && npm run build` +- Test: WS connection without token message closes with error. WS connection with valid token gets `ready` response. + +**Commit:** `fix(security): move WS auth token from URL to initial message frame` + +--- + +## Phase 3: P1 Infrastructure Middleware (1-2 hours) + +### Task 5: Rate Limiter Middleware + +**Objective:** Add per-IP rate limiting to sensitive endpoints. + +**Files:** +- Create: `internal/middleware/ratelimit.go` +- Modify: `cmd/server/main.go` + +**Create `internal/middleware/ratelimit.go`:** +```go +package middleware + +import ( + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// ipLimiter tracks rate limiters per IP address. +type ipLimiter struct { + mu sync.Mutex + limiters map[string]*rate.Limiter + rate rate.Limit + burst int +} + +func newIPLimiter(r rate.Limit, burst int) *ipLimiter { + return &ipLimiter{ + limiters: make(map[string]*rate.Limiter), + rate: r, + burst: burst, + } +} + +func (l *ipLimiter) getLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + lim, exists := l.limiters[ip] + if !exists { + lim = rate.NewLimiter(l.rate, l.burst) + l.limiters[ip] = lim + } + return lim +} + +// RateLimit returns middleware that limits requests per IP. +// rate is requests per second, burst is the burst size. +func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler { + limiter := newIPLimiter(rate.Limit(requestsPerSecond), burst) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + // If behind a proxy, use X-Forwarded-For or X-Real-IP + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ip = xff + } else if xri := r.Header.Get("X-Real-IP"); xri != "" { + ip = xri + } + + if !limiter.getLimiter(ip).Allow() { + http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) + } +} +``` + +**Apply in `cmd/server/main.go`:** + +For the auth routes (strict limiting): +```go +r.Route("/api/v1/auth", func(r chi.Router) { + r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10 + authHandler.RegisterPublicRoutes(r) +}) +``` + +For the invite join endpoint (moderate limiting): +```go +r.Route("/invites", func(r chi.Router) { + r.Use(middleware.RateLimit(2, 5)) // 2 req/s, burst 5 + ... +}) +``` + +For the global API (lenient limiting): +```go +r.Route("/api/v1", func(r chi.Router) { + r.Use(middleware.RateLimit(50, 100)) // 50 req/s, burst 100 + ... +}) +``` + +**Verify:** `go build ./...` compiles. Send 15 rapid requests to `/api/v1/auth/login/password`; the 11th+ should get 429. + +**Commit:** `feat(security): add per-IP rate limiting middleware` + +--- + +### Task 6: Security Headers Middleware + +**Objective:** Add standard security headers to all responses. + +**Files:** +- Create: `internal/middleware/security.go` +- Modify: `cmd/server/main.go` + +**Create `internal/middleware/security.go`:** +```go +package middleware + +import "net/http" + +// SecurityHeaders returns middleware that sets common security headers. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()") + next.ServeHTTP(w, r) + }) +} +``` + +Note: HSTS (`Strict-Transport-Security`) is intentionally omitted here because Caddy handles it at the reverse proxy layer. If the app is ever served directly, add it conditionally when TLS is detected. + +**Apply in `cmd/server/main.go`:** +```go +r := chi.NewRouter() +r.Use(chimw.Logger) +r.Use(chimw.Recoverer) +r.Use(chimw.RequestID) +r.Use(middleware.SecurityHeaders) // <-- add this +``` + +**Verify:** `go build ./...`. Check response headers include all the new headers. + +**Commit:** `feat(security): add security headers middleware` + +--- + +### Task 7: WS Broadcast Filtering (Cross-Server Data Leak) + +**Objective:** Only broadcast events to clients who are members of the relevant server. + +This is the most architecturally significant change. The hub needs to know which server each event belongs to, and which servers each user is a member of. + +**Files:** +- Modify: `internal/gateway/hub.go` +- Modify: `internal/gateway/client.go` +- Modify: `internal/gateway/events.go` +- Modify: callers that use `BroadcastEvent` + +**Step 1: Add server context to Event** + +In `internal/gateway/events.go`, update the Event struct: +```go +type Event struct { + Type string `json:"type"` + Data interface{} `json:"data,omitempty"` + ServerID string `json:"-"` // not serialized, used for routing +} +``` + +**Step 2: Add membership tracking to Hub** + +Add to `hub.go`: +```go +type Hub struct { + // ... existing fields ... + userServers map[string]map[string]struct{} // userID -> set of serverIDs + db *sql.DB +} + +// RefreshUserServers loads the server membership for a user from the database. +func (h *Hub) RefreshUserServers(userID string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + rows, err := h.db.QueryContext(ctx, `SELECT server_id FROM members WHERE user_id = $1`, userID) + if err != nil { + h.logger.Error("failed to load user servers", "user_id", userID, "error", err) + return + } + defer rows.Close() + + servers := make(map[string]struct{}) + for rows.Next() { + var sid string + if err := rows.Scan(&sid); err == nil { + servers[sid] = struct{}{} + } + } + + h.mu.Lock() + h.userServers[userID] = servers + h.mu.Unlock() +} +``` + +Call `RefreshUserServers` when a client connects (in `Register`) and when they leave (clean up in `Unregister`). + +**Step 3: Add `BroadcastToServer` method** + +```go +// BroadcastToServer sends an event only to clients who are members of the given server. +func (h *Hub) BroadcastToServer(serverID string, event Event) { + data, err := json.Marshal(event) + if err != nil { + h.logger.Error("failed to marshal event", "type", event.Type, "error", err) + return + } + + h.mu.RLock() + defer h.mu.RUnlock() + + for client := range h.clients { + servers, ok := h.userServers[client.UserID] + if !ok { + continue + } + if _, member := servers[serverID]; member { + select { + case client.send <- data: + default: + go func(c *Client) { h.unregister <- c }(client) + } + } + } +} +``` + +**Step 4: Update callers** + +Everywhere that currently calls `hub.BroadcastEvent(...)` for server-scoped events (messages, channels, reactions, webhooks) needs to: +1. Set `ServerID` on the event, OR +2. Call `hub.BroadcastToServer(serverID, event)` instead. + +Key callers to update: +- `internal/message/handlers.go` (Create, Update, Delete) -- need the serverID for the channel +- `internal/channel/handlers.go` (CRUD events) +- `internal/reaction/handlers.go` +- `internal/webhook/handlers.go` (Execute) +- `internal/bot/commands.go` (if it broadcasts events) + +Keep `BroadcastEvent` for truly global events like `PRESENCE_UPDATE`. + +**Verify:** User in Server A does not see messages from Server B over WS. + +**Commit:** `fix(security): scope WebSocket broadcasts to server members` + +--- + +### Task 8: Webhook Token Hashing + +**Objective:** Store webhook tokens as SHA-256 hashes, not plaintext. + +**Files:** +- Modify: `internal/webhook/token.go` +- Modify: `internal/webhook/handlers.go` +- Modify: `internal/db/db.go` (migration for new column) +- Create: migration or alter existing schema + +**Step 1: Add hash functions to `internal/webhook/token.go`:** +```go +package webhook + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" +) + +// genToken generates a 64-character hex token using crypto/rand. +func genToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// hashToken returns the SHA-256 hex digest of a token. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} +``` + +**Step 2: Add `token_hash` column to the schema in `internal/db/db.go`:** + +Add after the existing webhooks CREATE TABLE: +```sql +ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS token_hash VARCHAR(64); +CREATE INDEX IF NOT EXISTS idx_webhooks_token_hash ON webhooks(token_hash); +``` + +**Step 3: Update `webhook/handlers.go` Create:** + +Store the hash, return the plaintext token to the user once: +```go +token := genToken() +tokenHash := hashToken(token) + +// INSERT uses token_hash instead of token +_, err = h.db.ExecContext(r.Context(), ` + INSERT INTO webhooks (channel_id, name, token_hash, avatar, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, channel_id, name, avatar, created_by, created_at::text +`, channelID, req.Name, tokenHash, req.Avatar, userID) +``` + +**Step 4: Update `webhook/handlers.go` Execute:** + +Compare hashes instead of plaintext: +```go +var storedHash, channelID, webhookName string +err := h.db.QueryRowContext(r.Context(), ` + SELECT token_hash, channel_id, name, avatar FROM webhooks WHERE id = $1 +`, webhookID).Scan(&storedHash, &channelID, &webhookName, &webhookAvatar) + +if hashToken(token) != storedHash { + writeErr(w, http.StatusUnauthorized, "invalid token") + return +} +``` + +**Step 5: Migration for existing tokens:** + +If there are existing webhooks with plaintext tokens, add a one-time migration: +```sql +UPDATE webhooks SET token_hash = encode(sha256(token::bytea), 'hex') WHERE token_hash IS NULL; +ALTER TABLE webhooks DROP COLUMN IF EXISTS token; +ALTER TABLE webhooks ALTER COLUMN token_hash SET NOT NULL; +``` + +This is destructive (old plaintext tokens become unusable since we can't reverse the hash). Only run after confirming all webhook users have the new token format, OR regenerate all tokens and notify users. + +**Verify:** Create webhook, get token back. Use token to execute webhook (works). Use wrong token (401). Check DB: only hash stored. + +**Commit:** `fix(security): store webhook tokens as SHA-256 hashes` + +--- + +## Phase 4: P2 Fixes (1-2 hours) + +### Task 9: CSRF Protection + +**Objective:** Validate Origin/Referer headers on state-changing requests. + +**Files:** +- Create: `internal/middleware/csrf.go` +- Modify: `cmd/server/main.go` + +**Create `internal/middleware/csrf.go`:** +```go +package middleware + +import ( + "net/http" + "strings" +) + +// CSRFProtect returns middleware that validates the Origin header on +// state-changing methods (POST, PUT, PATCH, DELETE). +// trustedOrigins is the list of allowed origins. +func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler { + originSet := make(map[string]struct{}, len(trustedOrigins)) + for _, o := range trustedOrigins { + originSet[o] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only check state-changing methods + switch r.Method { + case "POST", "PUT", "PATCH", "DELETE": + origin := r.Header.Get("Origin") + if origin == "" { + // No Origin header: check Referer as fallback + referer := r.Header.Get("Referer") + if referer != "" { + // Extract origin from referer + for _, o := range trustedOrigins { + if strings.HasPrefix(referer, o) { + origin = o + break + } + } + } + } + + if origin != "" { + if _, ok := originSet[origin]; !ok { + http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) + return + } + } + // If both Origin and Referer are empty, allow through + // (could be a non-browser client like curl/bots) + } + next.ServeHTTP(w, r) + }) + } +} +``` + +**Apply in `cmd/server/main.go`:** +```go +r.Route("/api/v1", func(r chi.Router) { + r.Use(middleware.Session(sessionStore, cfg)) + r.Use(middleware.RequireAuth) + r.Use(middleware.CSRFProtect([]string{"https://" + cfg.Host, "http://localhost:" + cfg.Port})) + // ... +}) +``` + +Only apply to the authenticated API group, not to public webhook execution or health checks. + +**Verify:** POST from `http://evil.com` origin gets 403. POST from allowed origin succeeds. + +**Commit:** `feat(security): add Origin-based CSRF protection middleware` + +--- + +### Task 10: Channel Permission Checks + +**Objective:** Enforce `MANAGE_CHANNELS` permission on channel update/delete. + +**Files:** +- Modify: `internal/channel/handlers.go` +- Modify: `cmd/server/main.go` (to pass the permissions checker) + +**Step 1:** Add a `Checker` field to the channel handler: +```go +type Handler struct { + db *sql.DB + checker *permissions.Checker +} + +func NewHandler(db *sql.DB, checker *permissions.Checker) *Handler { + return &Handler{db: db, checker: checker} +} +``` + +**Step 2:** Update `cmd/server/main.go` to pass the checker: +```go +permissionsChecker := permissions.NewChecker(database.DB) +// ... +channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) +``` + +**Step 3:** In `channel/handlers.go` `Update`, add permission check after membership verification: +```go +// Check MANAGE_CHANNELS permission +allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) +if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return +} +if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return +} +``` + +**Step 4:** In `Delete`, the server owner check is fine but also add permission check for non-owners with `MANAGE_CHANNELS`: +```go +if ownerID != userID { + allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) + if err != nil || !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } +} +``` + +**Verify:** User with no special roles gets 403 on channel update. User with `MANAGE_CHANNELS` role can update. Server owner can always delete. + +**Commit:** `fix(security): enforce MANAGE_CHANNELS permission on channel update/delete` + +--- + +### Task 11: Upload File Validation + +**Objective:** Validate file types and enforce size limits on upload. + +**Files:** +- Modify: `internal/upload/handlers.go` + +**Step 1:** Add allowed extensions and a max file size constant: +```go +const maxUploadSize = 25 << 20 // 25 MB per file + +var allowedExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".mp3": true, ".wav": true, ".ogg": true, ".flac": true, + ".mp4": true, ".webm": true, ".mov": true, + ".pdf": true, ".txt": true, ".md": true, ".json": true, ".csv": true, + ".zip": true, ".tar": true, ".gz": true, +} +``` + +**Step 2:** In the `Upload` handler, add validation before the MinIO put: +```go +func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, `{"error":"file too large or invalid form"}`, http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest) + return + } + defer file.Close() + + // Validate extension + ext := "" + if idx := strings.LastIndex(header.Filename, "."); idx >= 0 { + ext = strings.ToLower(header.Filename[idx:]) + } + if !allowedExtensions[ext] { + http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest) + return + } + + // Validate content type (server-side detection) + buf := make([]byte, 512) + n, _ := file.Read(buf) + detectedType := http.DetectContentType(buf[:n]) + // Reset read position + file.Seek(0, io.SeekStart) + + // Block HTML/JS that might be served inline + blockedTypes := []string{"text/html", "application/javascript", "application/x-executable"} + for _, blocked := range blockedTypes { + if strings.HasPrefix(detectedType, blocked) { + http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest) + return + } + } + + // ... rest of upload logic unchanged ... +``` + +**Verify:** Upload a `.jpg` succeeds. Upload a `.exe` or `.html` gets 400. Upload a 30MB file gets rejected. + +**Commit:** `fix(security): validate file types and enforce upload size limits` + +--- + +### Task 12: File Serve Path Sanitization + +**Objective:** Prevent path traversal in file serving. + +**Files:** +- Modify: `internal/upload/handlers.go` + +**Update `Serve` handler:** +```go +func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) { + objectName := strings.TrimPrefix(r.URL.Path, "/files/") + if objectName == "" { + http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest) + return + } + + // Sanitize: reject path traversal attempts + if strings.Contains(objectName, "..") || strings.Contains(objectName, "/") { + http.Error(w, `{"error":"invalid path"}`, http.StatusBadRequest) + return + } + + // Set Content-Disposition: attachment to prevent inline rendering + w.Header().Set("Content-Disposition", "attachment") + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + obj, err := h.client.GetObject(ctx, h.bucket, objectName, minio.GetObjectOptions{}) + if err != nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + defer obj.Close() + + stat, err := obj.Stat() + if err != nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", stat.ContentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) + io.Copy(w, obj) +} +``` + +**Verify:** `GET /files/../../etc/passwd` returns 400. `GET /files/.jpg` works. + +**Commit:** `fix(security): sanitize file serve paths and add Content-Disposition` + +--- + +## Execution Order + +1. **Task 1** (WS Origin) + **Task 2** (WebAuthn Cookie) + **Task 3** (DB sslmode) -- independent, fast +2. **Task 4** (WS Token Transport) -- needs Task 1 done first +3. **Task 5** (Rate Limiter) + **Task 6** (Security Headers) -- independent, create middleware +4. **Task 7** (WS Broadcast Filtering) -- independent but complex +5. **Task 8** (Webhook Token Hashing) -- independent +6. **Task 9** (CSRF) -- benefits from Rate Limiter existing +7. **Task 10** (Channel Perms) + **Task 11** (Upload Validation) + **Task 12** (Path Sanitization) -- independent + +Total estimated time: 4-6 hours for all 12 tasks. + +--- + +## Post-Remediation Checklist + +- [ ] `go build ./cmd/server` succeeds +- [ ] `go build ./cmd/tui` succeeds +- [ ] `cd web && npm run build` succeeds +- [ ] `govulncheck ./...` still clean +- [ ] Manual test: login, send message, upload file, use webhook +- [ ] Manual test: WS from wrong origin rejected +- [ ] Manual test: rate limiting fires on brute-force login +- [ ] Manual test: non-member doesn't see cross-server events diff --git a/cmd/server/main.go b/cmd/server/main.go index 4a63ace..ec08c22 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,10 +14,12 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" @@ -139,6 +141,8 @@ func main() { r.Post("/", serverHandler.Create) r.Get("/", serverHandler.List) + modHandler := moderation.NewHandler(database.DB, hub) + r.Route("/{serverID}", func(r chi.Router) { r.Get("/", serverHandler.Get) r.Patch("/", serverHandler.Update) @@ -147,6 +151,14 @@ func main() { // 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.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) + // Channels (nested under servers) r.Route("/channels", func(r chi.Router) { channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) @@ -154,6 +166,12 @@ func main() { }) }) + // Direct messages + dmHandler := dm.NewHandler(database.DB, hub, logger) + r.Route("/conversations", func(r chi.Router) { + dmHandler.RegisterRoutes(r) + }) + // Roles and member-role assignment roleHandler := server.NewRoleHandler(database.DB) roleHandler.RegisterRoleRoutes(r) diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 1e32389..986a5cf 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -37,13 +37,14 @@ type createChannelRequest struct { } type channelResponse struct { - ID string `json:"id"` - ServerID string `json:"server_id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Position int `json:"position"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Position int `json:"position"` + SlowmodeSeconds int `json:"slowmode_seconds"` + CreatedAt string `json:"created_at"` } // isMember checks whether the given user is a member of the given server. @@ -124,11 +125,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { var ch channelResponse err = h.db.QueryRowContext(r.Context(), ` - INSERT INTO channels (server_id, name, type, category, position) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, server_id, name, type, category, position, created_at + INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds) + VALUES ($1, $2, $3, $4, $5, 0) + RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at `, serverID, req.Name, channelType, category, position).Scan( - &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt, + &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt, ) if err != nil { http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict) @@ -175,7 +176,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { } rows, err := h.db.QueryContext(r.Context(), ` - SELECT id, server_id, name, type, category, position, created_at + SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at FROM channels WHERE server_id = $1 ORDER BY position, name @@ -189,7 +190,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { channels := make([]channelResponse, 0) for rows.Next() { var ch channelResponse - if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt); err != nil { + if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } @@ -226,9 +227,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { var ch channelResponse err := h.db.QueryRowContext(r.Context(), ` - SELECT id, server_id, name, type, category, position, created_at + SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at FROM channels WHERE id = $1 - `, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt) + `, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) @@ -253,10 +254,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { } type updateChannelRequest struct { - Name *string `json:"name"` - Type *string `json:"type"` - Category *string `json:"category"` - Position *int `json:"position"` + Name *string `json:"name"` + Type *string `json:"type"` + Category *string `json:"category"` + Position *int `json:"position"` + SlowmodeSeconds *int `json:"slowmode_seconds"` } // @Summary Update a channel @@ -287,7 +289,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } - if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil { + if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil { http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) return } @@ -330,11 +332,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { SET name = COALESCE($1, name), type = COALESCE($2, type), category = COALESCE($3, category), - position = COALESCE($4, position) - WHERE id = $5 - RETURNING id, server_id, name, type, category, position, created_at - `, req.Name, req.Type, req.Category, req.Position, channelID).Scan( - &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt, + position = COALESCE($4, position), + slowmode_seconds = COALESCE($5, slowmode_seconds) + WHERE id = $6 + RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at + `, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID).Scan( + &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt, ) if err != nil { http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError) diff --git a/internal/db/db.go b/internal/db/db.go index a35d472..49c6efc 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -214,4 +214,89 @@ CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); + +-- Direct message conversations +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(16) NOT NULL DEFAULT 'dm', + name VARCHAR(100), + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS conversation_members ( + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (conversation_id, user_id) +); + +CREATE TABLE IF NOT EXISTS conversation_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content VARCHAR(4000) NOT NULL, + edited_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id); +CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC); + +-- Moderation +CREATE TABLE IF NOT EXISTS bans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + banned_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (server_id, user_id) +); + +CREATE TABLE IF NOT EXISTS server_mutes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + muted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (server_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id); +CREATE INDEX IF NOT EXISTS idx_server_mutes_server ON server_mutes(server_id); + +-- Link embeds +CREATE TABLE IF NOT EXISTS embeds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + url TEXT NOT NULL, + title TEXT, + description TEXT, + image_url TEXT, + site_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +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; + +-- 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); + +CREATE OR REPLACE FUNCTION messages_search_update() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := to_tsvector('english', COALESCE(NEW.content, '')); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS messages_search_trigger ON messages; +CREATE TRIGGER messages_search_trigger +BEFORE INSERT OR UPDATE ON messages +FOR EACH ROW EXECUTE FUNCTION messages_search_update(); ` diff --git a/internal/dm/handlers.go b/internal/dm/handlers.go new file mode 100644 index 0000000..fdf0250 --- /dev/null +++ b/internal/dm/handlers.go @@ -0,0 +1,426 @@ +package dm + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "sort" + "strconv" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +// Handler handles direct-message conversations. +type Handler struct { + db *sql.DB + hub *gateway.Hub + logger *slog.Logger +} + +// NewHandler creates a new DM handler. +func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler { + return &Handler{db: db, hub: hub, logger: logger} +} + +// RegisterRoutes registers conversation routes. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/", h.Create) + r.Get("/", h.List) + r.Route("/{conversationID}", func(r chi.Router) { + r.Get("/", h.Get) + r.Get("/messages", h.ListMessages) + r.Post("/messages", h.SendMessage) + }) +} + +type createConversationRequest struct { + UserIDs []string `json:"user_ids"` +} + +type conversationResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Members []member `json:"members"` + CreatedAt string `json:"created_at"` +} + +type member struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` +} + +// Create creates a new DM or group DM. +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req createConversationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if len(req.UserIDs) == 0 { + http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest) + return + } + + // Build unique member set including creator. + memberSet := map[string]struct{}{userID: {}} + for _, uid := range req.UserIDs { + if uid != "" { + memberSet[uid] = struct{}{} + } + } + if len(memberSet) == 1 { + http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest) + return + } + + memberIDs := make([]string, 0, len(memberSet)) + for uid := range memberSet { + memberIDs = append(memberIDs, uid) + } + sort.Strings(memberIDs) + + // For a 1:1 DM, check if it already exists. + if len(memberIDs) == 2 { + var existingID string + err := h.db.QueryRowContext(r.Context(), ` + SELECT c.id FROM conversations c + WHERE c.type = 'dm' + AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversation_id = c.id) = 2 + AND NOT EXISTS ( + SELECT 1 FROM conversation_members cm2 + WHERE cm2.conversation_id = c.id AND cm2.user_id NOT IN ($1, $2) + ) + LIMIT 1 + `, memberIDs[0], memberIDs[1]).Scan(&existingID) + if err == nil { + h.getByID(w, r, existingID) + return + } + if !errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + } + + convType := "dm" + if len(memberIDs) > 2 { + convType = "group_dm" + } + + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var convID string + var createdAt string + err = tx.QueryRowContext(r.Context(), ` + INSERT INTO conversations (type, created_by) + VALUES ($1, $2) + RETURNING id, created_at::text + `, convType, userID).Scan(&convID, &createdAt) + if err != nil { + http.Error(w, `{"error":"failed to create conversation"}`, http.StatusInternalServerError) + return + } + + for _, uid := range memberIDs { + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1, $2) + `, convID, uid) + if err != nil { + http.Error(w, `{"error":"failed to add member"}`, http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + resp, err := h.buildResponse(r.Context(), convID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(resp) +} + +// Get returns a single conversation. +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + convID := chi.URLParam(r, "conversationID") + resp, err := h.buildResponse(r.Context(), convID) + if err != nil { + http.Error(w, `{"error":"conversation not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (h *Handler) getByID(w http.ResponseWriter, r *http.Request, convID string) { + resp, err := h.buildResponse(r.Context(), convID) + if err != nil { + http.Error(w, `{"error":"conversation not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// List returns the current user's conversations. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT c.id FROM conversations c + JOIN conversation_members cm ON cm.conversation_id = c.id + WHERE cm.user_id = $1 + ORDER BY c.created_at DESC + `, userID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + var conversations []conversationResponse + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + resp, err := h.buildResponse(r.Context(), id) + if err != nil { + continue + } + conversations = append(conversations, resp) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(conversations) +} + +func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) { + var resp conversationResponse + err := h.db.QueryRowContext(ctx, ` + SELECT id, type, name, created_at::text FROM conversations WHERE id = $1 + `, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt) + if err != nil { + return resp, err + } + + memberRows, err := h.db.QueryContext(ctx, ` + SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, '') + FROM conversation_members cm + JOIN users u ON u.id = cm.user_id + WHERE cm.conversation_id = $1 + `, convID) + if err != nil { + return resp, err + } + defer memberRows.Close() + + for memberRows.Next() { + var m member + if err := memberRows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar); err != nil { + continue + } + resp.Members = append(resp.Members, m) + } + return resp, nil +} + +type messageResponse struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_username"` + DisplayName *string `json:"author_display_name"` + Content string `json:"content"` + EditedAt *string `json:"edited_at"` + CreatedAt string `json:"created_at"` +} + +// ListMessages lists messages in a conversation. +func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + convID := chi.URLParam(r, "conversationID") + if !h.isMember(r.Context(), convID, userID) { + http.Error(w, `{"error":"not a member"}`, 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 + } + } + before := r.URL.Query().Get("before") + + var rows *sql.Rows + var err error + if before != "" { + rows, err = h.db.QueryContext(r.Context(), ` + SELECT m.id, m.conversation_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text + FROM conversation_messages m + JOIN users u ON u.id = m.author_id + WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2) + ORDER BY m.created_at ASC + LIMIT $3 + `, convID, before, limit) + } else { + rows, err = h.db.QueryContext(r.Context(), ` + SELECT m.id, m.conversation_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text + FROM conversation_messages m + JOIN users u ON u.id = m.author_id + WHERE m.conversation_id = $1 + ORDER BY m.created_at ASC + LIMIT $2 + `, convID, limit) + } + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + messages := make([]messageResponse, 0) + for rows.Next() { + var msg messageResponse + var editedAt sql.NullString + var createdAt sql.NullString + var displayName sql.NullString + if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.AuthorID, &msg.AuthorName, &displayName, &msg.Content, &editedAt, &createdAt); err != nil { + continue + } + if editedAt.Valid { + msg.EditedAt = &editedAt.String + } + msg.CreatedAt = createdAt.String + if displayName.Valid { + msg.DisplayName = &displayName.String + } + messages = append(messages, msg) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +// SendMessage sends a message to a conversation. +func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + convID := chi.URLParam(r, "conversationID") + if !h.isMember(r.Context(), convID, userID) { + http.Error(w, `{"error":"not a member"}`, http.StatusForbidden) + return + } + + var req struct { + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Content == "" || len(req.Content) > 4000 { + http.Error(w, `{"error":"invalid content"}`, http.StatusBadRequest) + return + } + + var msg messageResponse + var editedAt sql.NullString + var createdAt sql.NullString + var displayName sql.NullString + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO conversation_messages (conversation_id, author_id, content) + VALUES ($1, $2, $3) + RETURNING id, conversation_id, author_id, content, edited_at::text, created_at::text + `, convID, userID, req.Content).Scan(&msg.ID, &msg.ConversationID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt) + if err != nil { + http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError) + return + } + + err = h.db.QueryRowContext(r.Context(), `SELECT username, display_name FROM users WHERE id = $1`, userID).Scan(&msg.AuthorName, &displayName) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if editedAt.Valid { + msg.EditedAt = &editedAt.String + } + msg.CreatedAt = createdAt.String + if displayName.Valid { + msg.DisplayName = &displayName.String + } + + if h.hub != nil { + h.hub.BroadcastToConversation(convID, gateway.Event{ + Type: gateway.EventMessageCreate, + Data: msg, + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(msg) +} + +func (h *Handler) isMember(ctx context.Context, convID, userID string) bool { + var exists bool + err := h.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2)`, convID, userID).Scan(&exists) + return err == nil && exists +} + +// MemberIDs returns all user IDs in a conversation. +func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error) { + rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM conversation_members WHERE conversation_id = $1`, convID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + ids = append(ids, id) + } + } + return ids, nil +} diff --git a/internal/embed/embed.go b/internal/embed/embed.go new file mode 100644 index 0000000..649437c --- /dev/null +++ b/internal/embed/embed.go @@ -0,0 +1,149 @@ +package embed + +import ( + "context" + "database/sql" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +var urlRegex = regexp.MustCompile(`https?://[^\s<>"{}|\^\[\]]+`) + +// Embed represents a stored link preview. +type Embed struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + SiteName string `json:"site_name"` +} + +// ExtractURLs returns all HTTP/HTTPS URLs found in text. +func ExtractURLs(text string) []string { + matches := urlRegex.FindAllString(text, -1) + seen := make(map[string]struct{}) + var out []string + for _, u := range matches { + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + out = append(out, u) + } + if len(out) > 5 { + out = out[:5] + } + return out +} + +// FetchEmbed retrieves OpenGraph meta tags for a URL. +func FetchEmbed(url string) *Embed { + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DumpsterBot/1.0)") + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + if err != nil { + return nil + } + html := string(body) + return &Embed{ + URL: url, + Title: ogTag(html, "og:title"), + Description: ogTag(html, "og:description"), + ImageURL: ogTag(html, "og:image"), + SiteName: ogTag(html, "og:site_name"), + } +} + +func ogTag(html, property string) string { + // Try property + re := regexp.MustCompile(`]+property=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`) + m := re.FindStringSubmatch(html) + if len(m) > 1 && m[1] != "" { + return strings.TrimSpace(m[1]) + } + // Try name + re = regexp.MustCompile(`]+name=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`) + m = re.FindStringSubmatch(html) + if len(m) > 1 && m[1] != "" { + return strings.TrimSpace(m[1]) + } + // Fallback for title + if property == "og:title" { + re = regexp.MustCompile(`]*>([^<]+)`) + m = re.FindStringSubmatch(html) + if len(m) > 1 { + return strings.TrimSpace(m[1]) + } + } + return "" +} + +// FetchAndStore extracts URLs from content, fetches embeds, and stores them. +func FetchAndStore(db *sql.DB, messageID string, content string) { + urls := ExtractURLs(content) + if len(urls) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + for _, url := range urls { + emb := FetchEmbed(url) + if emb == nil || (emb.Title == "" && emb.Description == "" && emb.ImageURL == "") { + continue + } + _, err := db.ExecContext(ctx, ` + INSERT INTO embeds (message_id, url, title, description, image_url, site_name) + VALUES ($1, $2, $3, $4, $5, $6) + `, messageID, emb.URL, emb.Title, emb.Description, emb.ImageURL, emb.SiteName) + if err != nil { + fmt.Printf("failed to store embed: %v\n", err) + } + } +} + +// LoadForMessages returns embeds grouped by message_id. +func LoadForMessages(ctx context.Context, db *sql.DB, messageIDs []string) (map[string][]Embed, error) { + result := make(map[string][]Embed) + if len(messageIDs) == 0 { + return result, nil + } + placeholders := make([]string, len(messageIDs)) + args := make([]interface{}, len(messageIDs)) + for i, id := range messageIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + query := "SELECT message_id, id, url, title, description, image_url, site_name FROM embeds WHERE message_id IN (" + strings.Join(placeholders, ",") + ")" + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var e Embed + var mid string + if err := rows.Scan(&mid, &e.ID, &e.URL, &e.Title, &e.Description, &e.ImageURL, &e.SiteName); err != nil { + continue + } + result[mid] = append(result[mid], e) + } + return result, nil +} diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go index 6046cf4..41ca094 100644 --- a/internal/gateway/hub.go +++ b/internal/gateway/hub.go @@ -281,6 +281,46 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) { } } +// BroadcastToConversation sends an event to all clients who are members of a +// given direct-message conversation. +func (h *Hub) BroadcastToConversation(convID string, event Event) { + data, err := json.Marshal(event) + if err != nil { + h.logger.Error("failed to marshal event", "type", event.Type, "error", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM conversation_members WHERE conversation_id = $1`, convID) + if err != nil { + h.logger.Error("failed to load conversation members", "conversation_id", convID, "error", err) + return + } + defer rows.Close() + + members := make(map[string]struct{}) + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err == nil { + members[uid] = struct{}{} + } + } + + h.mu.RLock() + defer h.mu.RUnlock() + for client := range h.clients { + if _, ok := members[client.UserID]; ok { + select { + case client.send <- data: + default: + go func(c *Client) { h.unregister <- c }(client) + } + } + } +} + // ServerIDForChannel looks up the server_id that owns the given channel. // Used by handlers to route events to the correct server scope. func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) { diff --git a/internal/message/handlers.go b/internal/message/handlers.go index f345172..266c20a 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -8,8 +8,11 @@ import ( "log/slog" "net/http" "strconv" + "strings" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "github.com/go-chi/chi/v5" @@ -42,19 +45,31 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.List) r.Post("/", h.Create) + r.Get("/search", h.Search) r.Patch("/{messageID}", h.Update) r.Delete("/{messageID}", h.Delete) } +type embedResponse struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + SiteName string `json:"site_name"` +} + type messageResponse struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - AuthorID string `json:"author_id"` - AuthorName string `json:"author_username"` - DisplayName *string `json:"author_display_name"` - Content string `json:"content"` - EditedAt *string `json:"edited_at"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ChannelID string `json:"channel_id"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_username"` + DisplayName *string `json:"author_display_name"` + Content string `json:"content"` + ReplyTo *string `json:"reply_to,omitempty"` + EditedAt *string `json:"edited_at"` + CreatedAt string `json:"created_at"` + Embeds []embedResponse `json:"embeds"` } // isMember checks whether the given user is a member of the given server. @@ -105,6 +120,29 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { return } + // Check server mute. + muted, err := moderation.IsMuted(r.Context(), h.db, serverID, userID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if muted { + http.Error(w, `{"error":"you are muted in this server"}`, http.StatusForbidden) + return + } + + // Check slowmode. + if retryAfter, active := h.checkSlowmode(r.Context(), channelID, userID); active { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "slowmode", + "retry_after": retryAfter, + "retry_after_ms": retryAfter * 1000, + }) + return + } + var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString @@ -112,12 +150,12 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { if req.ReplyTo != nil { replyTo = sql.NullString{String: *req.ReplyTo, Valid: true} } - err := h.db.QueryRowContext(r.Context(), ` + err = h.db.QueryRowContext(r.Context(), ` INSERT INTO messages (channel_id, author_id, content, reply_to) VALUES ($1, $2, $3, $4) - RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text + RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text `, channelID, userID, req.Content, replyTo).Scan( - &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt, ) if err != nil { http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError) @@ -133,6 +171,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { return } + if replyTo.Valid { + msg.ReplyTo = &replyTo.String + } if editedAt.Valid { msg.EditedAt = &editedAt.String } @@ -154,14 +195,43 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { if channelName == "" { channelName = channelID } - h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content) + if h.pushHandler != nil { + h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content) + } }() + // Fetch and store link embeds asynchronously. + go embed.FetchAndStore(h.db, msg.ID, req.Content) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(msg) } +func (h *Handler) checkSlowmode(ctx context.Context, channelID, userID string) (int, bool) { + var slowmode int + err := h.db.QueryRowContext(ctx, `SELECT slowmode_seconds FROM channels WHERE id = $1`, channelID).Scan(&slowmode) + if err != nil || slowmode <= 0 { + return 0, false + } + + var elapsed int + err = h.db.QueryRowContext(ctx, ` + SELECT COALESCE(EXTRACT(EPOCH FROM (NOW() - created_at))::int, 0) + FROM messages + WHERE channel_id = $1 AND author_id = $2 + ORDER BY created_at DESC + LIMIT 1 + `, channelID, userID).Scan(&elapsed) + if err != nil { + return 0, false + } + if elapsed < slowmode { + return slowmode - elapsed, true + } + return 0, false +} + type updateMessageRequest struct { Content string `json:"content"` } @@ -205,12 +275,13 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString + var replyTo sql.NullString err := h.db.QueryRowContext(r.Context(), ` UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2 AND author_id = $3 - RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text + RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text `, req.Content, messageID, userID).Scan( - &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -229,11 +300,17 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } + if replyTo.Valid { + msg.ReplyTo = &replyTo.String + } if editedAt.Valid { msg.EditedAt = &editedAt.String } msg.CreatedAt = createdAt.String + // Re-fetch embeds after edit. + msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds + // Look up serverID for scoped broadcast serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID) if serverID != "" { @@ -243,6 +320,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { }) } + go embed.FetchAndStore(h.db, msg.ID, req.Content) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(msg) } @@ -327,7 +406,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var err error if before != "" { rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text FROM messages m JOIN users u ON m.author_id = u.id WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2) @@ -336,7 +415,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { `, channelID, before, limit) } else { rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text FROM messages m JOIN users u ON m.author_id = u.id WHERE m.channel_id = $1 @@ -355,10 +434,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString - err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &editedAt, &createdAt) + var replyTo sql.NullString + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt) if err != nil { continue } + if replyTo.Valid { + msg.ReplyTo = &replyTo.String + } if editedAt.Valid { msg.EditedAt = &editedAt.String } @@ -366,12 +449,110 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { messages = append(messages, msg) } + messages = h.attachEmbeds(r.Context(), messages) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) _ = userID } +// attachEmbeds loads embeds for the given messages and attaches them. +func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse) []messageResponse { + if len(messages) == 0 { + return messages + } + ids := make([]string, len(messages)) + for i, m := range messages { + ids[i] = m.ID + } + embedMap, err := embed.LoadForMessages(ctx, h.db, ids) + if err != nil { + return messages + } + for i := range messages { + if embs, ok := embedMap[messages[i].ID]; ok { + out := make([]embedResponse, len(embs)) + for j, e := range embs { + out[j] = embedResponse{ + ID: e.ID, + URL: e.URL, + Title: e.Title, + Description: e.Description, + ImageURL: e.ImageURL, + SiteName: e.SiteName, + } + } + messages[i].Embeds = out + } + } + return messages +} + +// Search searches messages in a channel using PostgreSQL full-text search. +func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + _, _, ok := h.requireChannelAccess(w, r, channelID) + if !ok { + return + } + + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + http.Error(w, `{"error":"missing q parameter"}`, http.StatusBadRequest) + return + } + + limitStr := r.URL.Query().Get("limit") + limit := 25 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text, + ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank + FROM messages m + JOIN users u ON m.author_id = u.id + WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2) + ORDER BY rank DESC, m.created_at DESC + LIMIT $3 + `, channelID, q, limit) + if err != nil { + http.Error(w, `{"error":"failed to search messages"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + messages := make([]messageResponse, 0) + for rows.Next() { + var msg messageResponse + var editedAt sql.NullString + var createdAt sql.NullString + var replyTo sql.NullString + var rank float64 + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank) + if err != nil { + continue + } + if replyTo.Valid { + msg.ReplyTo = &replyTo.String + } + if editedAt.Valid { + msg.EditedAt = &editedAt.String + } + msg.CreatedAt = createdAt.String + messages = append(messages, msg) + } + + messages = h.attachEmbeds(r.Context(), messages) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + // requireChannelAccess checks if user is authenticated and is a member of the channel's server. // Returns (userID, serverID, ok). func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) { diff --git a/internal/moderation/handlers.go b/internal/moderation/handlers.go new file mode 100644 index 0000000..7c963c7 --- /dev/null +++ b/internal/moderation/handlers.go @@ -0,0 +1,207 @@ +package moderation + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "time" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +// Handler handles server moderation actions. +type Handler struct { + db *sql.DB + hub *gateway.Hub +} + +// NewHandler creates a new moderation handler. +func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { + return &Handler{db: db, hub: hub} +} + +type banRequest struct { + UserID string `json:"user_id"` + Reason string `json:"reason"` +} + +type muteRequest struct { + UserID string `json:"user_id"` + Reason string `json:"reason"` + Duration string `json:"duration"` // e.g. "15m", "1h", "1d", empty = permanent +} + +// banResponse is the JSON shape returned when banning a user. +type banResponse struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + UserID string `json:"user_id"` + BannedBy string `json:"banned_by"` + Reason string `json:"reason"` + CreatedAt string `json:"created_at"` +} + +// Ban bans a user from a server. +func (h *Handler) Ban(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 + } + + var req banRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + // Remove member if present. + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, req.UserID) + + var ban banResponse + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO bans (server_id, user_id, banned_by, reason) + VALUES ($1, $2, $3, $4) + RETURNING id, server_id, user_id, banned_by, reason, created_at::text + `, serverID, req.UserID, userID, req.Reason).Scan(&ban.ID, &ban.ServerID, &ban.UserID, &ban.BannedBy, &ban.Reason, &ban.CreatedAt) + if err != nil { + http.Error(w, `{"error":"failed to ban user"}`, http.StatusInternalServerError) + return + } + + if h.hub != nil { + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMemberRemove, + Data: map[string]string{"server_id": serverID, "user_id": req.UserID}, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ban) +} + +// Unban removes a ban. +func (h *Handler) Unban(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + targetID := chi.URLParam(r, "userID") + _, err := h.db.ExecContext(r.Context(), `DELETE FROM bans WHERE server_id = $1 AND user_id = $2`, serverID, targetID) + if err != nil { + http.Error(w, `{"error":"failed to unban user"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ListBans lists banned users. +func (h *Handler) ListBans(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + rows, err := h.db.QueryContext(r.Context(), ` + SELECT b.id, b.server_id, b.user_id, b.banned_by, b.reason, b.created_at::text, u.username + FROM bans b + JOIN users u ON u.id = b.user_id + WHERE b.server_id = $1 + ORDER BY b.created_at DESC + `, serverID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + type item struct { + banResponse + Username string `json:"username"` + } + var bans []item + for rows.Next() { + var b item + if err := rows.Scan(&b.ID, &b.ServerID, &b.UserID, &b.BannedBy, &b.Reason, &b.CreatedAt, &b.Username); err != nil { + continue + } + bans = append(bans, b) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(bans) +} + +// Kick removes a member from a server. +func (h *Handler) Kick(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + targetID := chi.URLParam(r, "userID") + _, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, targetID) + if err != nil { + http.Error(w, `{"error":"failed to kick user"}`, http.StatusInternalServerError) + return + } + if h.hub != nil { + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMemberRemove, + Data: map[string]string{"server_id": serverID, "user_id": targetID}, + }) + } + w.WriteHeader(http.StatusNoContent) +} + +// Mute creates a server mute. +func (h *Handler) Mute(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 + } + var req muteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + var expiresAt sql.NullTime + if req.Duration != "" { + d, err := time.ParseDuration(req.Duration) + if err != nil { + http.Error(w, `{"error":"invalid duration"}`, http.StatusBadRequest) + return + } + expiresAt = sql.NullTime{Time: time.Now().Add(d), Valid: true} + } + + _, err := h.db.ExecContext(r.Context(), ` + INSERT INTO server_mutes (server_id, user_id, muted_by, reason, expires_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (server_id, user_id) DO UPDATE SET muted_by = EXCLUDED.muted_by, reason = EXCLUDED.reason, expires_at = EXCLUDED.expires_at, created_at = NOW() + `, serverID, req.UserID, userID, req.Reason, expiresAt) + if err != nil { + http.Error(w, `{"error":"failed to mute user"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Unmute removes a server mute. +func (h *Handler) Unmute(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + targetID := chi.URLParam(r, "userID") + _, err := h.db.ExecContext(r.Context(), `DELETE FROM server_mutes WHERE server_id = $1 AND user_id = $2`, serverID, targetID) + if err != nil { + http.Error(w, `{"error":"failed to unmute user"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// IsMuted checks whether a user is currently muted in a server. +func IsMuted(ctx context.Context, db *sql.DB, serverID, userID string) (bool, error) { + var exists bool + err := db.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM server_mutes WHERE server_id = $1 AND user_id = $2 AND (expires_at IS NULL OR expires_at > NOW())) + `, serverID, userID).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return exists, err +} diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go index 2828898..df9b104 100644 --- a/internal/permissions/permissions.go +++ b/internal/permissions/permissions.go @@ -2,22 +2,33 @@ package permissions // Permission bitflags. const ( - VIEW_CHANNEL int64 = 1 << 0 - SEND_MESSAGES int64 = 1 << 1 - MANAGE_MESSAGES int64 = 1 << 2 - KICK_MEMBERS int64 = 1 << 3 - BAN_MEMBERS int64 = 1 << 4 - MANAGE_SERVER int64 = 1 << 5 - MANAGE_CHANNELS int64 = 1 << 6 - ADMINISTRATOR int64 = 1 << 7 - CONNECT_VOICE int64 = 1 << 8 - SPEAK_VOICE int64 = 1 << 9 - SHARE_SCREEN int64 = 1 << 10 + VIEW_CHANNEL int64 = 1 << 0 + SEND_MESSAGES int64 = 1 << 1 + MANAGE_MESSAGES int64 = 1 << 2 + KICK_MEMBERS int64 = 1 << 3 + BAN_MEMBERS int64 = 1 << 4 + MANAGE_SERVER int64 = 1 << 5 + MANAGE_CHANNELS int64 = 1 << 6 + ADMINISTRATOR int64 = 1 << 7 + CONNECT_VOICE int64 = 1 << 8 + SPEAK_VOICE int64 = 1 << 9 + SHARE_SCREEN int64 = 1 << 10 + MUTE_MEMBERS int64 = 1 << 11 + CREATE_INSTANT_INVITE int64 = 1 << 12 + CHANGE_NICKNAME int64 = 1 << 13 + MANAGE_NICKNAMES int64 = 1 << 14 + MANAGE_ROLES int64 = 1 << 15 + MANAGE_WEBHOOKS int64 = 1 << 16 + EMBED_LINKS int64 = 1 << 17 + ATTACH_FILES int64 = 1 << 18 + ADD_REACTIONS int64 = 1 << 19 + USE_EXTERNAL_EMOJIS int64 = 1 << 20 + MENTION_EVERYONE int64 = 1 << 21 ) // DefaultEveryonePermissions is granted to the @everyone role when a server is created. -// Bits 0,1,8,9,10 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN = 1539. -const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN +// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19. +const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS // Has reports whether the permission set contains the given permission bits. func Has(permissions int64, perm int64) bool { diff --git a/web/package-lock.json b/web/package-lock.json index 63d9e95..3b6b8fb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,9 @@ "livekit-client": "^2.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", "react-router-dom": "^6.23.1", + "remark-gfm": "^4.0.1", "zustand": "^4.5.2" }, "devDependencies": { @@ -1342,6 +1344,15 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/dom-mediacapture-record": { "version": "1.0.22", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", @@ -1353,21 +1364,51 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1384,6 +1425,18 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1470,6 +1523,16 @@ "postcss": "^8.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -1574,6 +1637,56 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1621,6 +1734,16 @@ "node": ">=6" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1655,14 +1778,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1676,6 +1797,41 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1756,6 +1912,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -1765,6 +1943,12 @@ "node": ">=0.8.x" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1893,6 +2077,86 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1922,6 +2186,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1945,6 +2219,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1955,6 +2239,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2078,6 +2374,16 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2100,6 +2406,286 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2110,6 +2696,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2128,7 +3277,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -2202,6 +3350,31 @@ "node": ">= 6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2412,6 +3585,16 @@ "dev": true, "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2458,6 +3641,33 @@ "react": "^18.3.1" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2523,6 +3733,72 @@ "node": ">=8.10.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2678,6 +3954,48 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2836,6 +4154,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2872,6 +4210,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2934,6 +4359,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3041,6 +4494,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web/package.json b/web/package.json index 447416c..26abad7 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,9 @@ "livekit-client": "^2.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", "react-router-dom": "^6.23.1", + "remark-gfm": "^4.0.1", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 2097d8c..e7f9201 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { BotManager } from './components/BotManager.tsx'; import { CommandManager } from './components/CommandManager.tsx'; import { RoleManager } from './components/RoleManager.tsx'; import { JoinServer } from './components/JoinServer.tsx'; +import { DMChat } from './components/DMChat.tsx'; import { useAuthStore } from './stores/auth.ts'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -109,6 +110,8 @@ function App() { > } /> } /> + } /> + } /> diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 226bc3d..c58619d 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -5,6 +5,9 @@ import { useServerStore } from "../stores/server.ts"; import { useMemberStore } from "../stores/member.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; import { MentionDropdown } from "./MentionDropdown"; +import { MessageSearch } from "./MessageSearch"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; function formatTime(iso: string): string { const date = new Date(iso); @@ -15,39 +18,85 @@ function formatTime(iso: string): string { function renderContent(content: string, memberUsernames: Set) { - // Split on @username, preserving mentions. - const parts: (string | { mention: string })[] = []; + // Split on @username, preserving mentions; render plain text segments as markdown. + const segments: { type: "text" | "mention"; value: string }[] = []; const mentionRe = /@([a-zA-Z0-9_.-]+)/g; let last = 0; let match: RegExpExecArray | null; while ((match = mentionRe.exec(content)) !== null) { if (match.index > last) { - parts.push(content.slice(last, match.index)); + segments.push({ type: "text", value: content.slice(last, match.index) }); } const username = match[1]; if (memberUsernames.has(username)) { - parts.push({ mention: username }); + segments.push({ type: "mention", value: username }); } else { - parts.push(match[0]); + segments.push({ type: "text", value: match[0] }); } last = mentionRe.lastIndex; } if (last < content.length) { - parts.push(content.slice(last)); + segments.push({ type: "text", value: content.slice(last) }); } - return parts.map((part, idx) => { - if (typeof part === "string") { - return {part}; + return segments.map((seg, idx) => { + if (seg.type === "mention") { + return ( + + @{seg.value} + + ); } return ( - - @{part.mention} - + , + code: ({ ...props }) => , + pre: ({ ...props }) =>
,
+          blockquote: ({ ...props }) => 
, + table: ({ ...props }) => , + th: ({ ...props }) =>
, + td: ({ ...props }) => , + p: ({ ...props }) => , + }} + > + {seg.value} + ); }); } +function renderEmbeds(embeds?: { url: string; title?: string; description?: string; image_url?: string; site_name?: string }[]) { + if (!embeds || embeds.length === 0) return null; + return ( + + ); +} + export function ChatArea() { const activeChannelId = useChannelStore((s) => s.activeChannelId); const channelsByServer = useChannelStore((s) => s.channelsByServer); @@ -62,7 +111,9 @@ export function ChatArea() { const [input, setInput] = useState(""); const [error, setError] = useState(null); const [showGifPicker, setShowGifPicker] = useState(false); + const [showSearch, setShowSearch] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); + const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); const bottomRef = useRef(null); const inputRef = useRef(null); @@ -87,6 +138,20 @@ export function ChatArea() { bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); + useEffect(() => { + if (slowmodeRemaining <= 0) return; + const t = setInterval(() => { + setSlowmodeRemaining((r) => { + if (r <= 1) { + clearInterval(t); + return 0; + } + return r - 1; + }); + }, 1000); + return () => clearInterval(t); + }, [slowmodeRemaining]); + const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursor = e.target.selectionStart ?? value.length; @@ -132,14 +197,25 @@ export function ChatArea() { const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (!activeChannelId || !input.trim()) return; + if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return; setError(null); try { await sendMessage(activeChannelId, input.trim()); setInput(""); setMentionQuery(null); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to send"); + const msg = err instanceof Error ? err.message : "Failed to send"; + // Parse slowmode error from API. + try { + const parsed = JSON.parse(msg); + if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") { + setSlowmodeRemaining(parsed.retry_after); + return; + } + } catch { + // not json + } + setError(msg); } }; @@ -156,13 +232,31 @@ export function ChatArea() { return (
-
- {activeChannel - ? `# ${activeChannel.name}` - : activeChannelId - ? `#${activeChannelId}` - : "[NO CHANNEL SELECTED]"} +
+ + {activeChannel + ? `# ${activeChannel.name}` + : activeChannelId + ? `#${activeChannelId}` + : "[NO CHANNEL SELECTED]"} + + {activeChannelId && ( + + )}
+ {showSearch && activeChannelId && activeChannel && ( + setShowSearch(false)} + /> + )}
{isLoading &&

[loading...]

} {!isLoading && messages.length === 0 && ( @@ -179,6 +273,7 @@ export function ChatArea() { {renderContent(message.content, memberUsernames)} + {renderEmbeds(message.embeds)}
))}
@@ -196,6 +291,11 @@ export function ChatArea() {

ERR: {error}

)} + {slowmodeRemaining > 0 && ( +
+ SLOWMODE: wait {slowmodeRemaining}s +
+ )}
{">"}
@@ -206,7 +306,7 @@ export function ChatArea() { onChange={handleInputChange} placeholder="type a message..." className="terminal-input w-full" - disabled={!activeChannelId} + disabled={!activeChannelId || slowmodeRemaining > 0} /> {mentionQuery !== null && ( m.id !== currentUserId); + return other?.username || "unknown"; +} + +export function ConversationList() { + const conversations = useConversationStore((s) => s.conversations); + const activeId = useConversationStore((s) => s.activeConversationId); + const fetchConversations = useConversationStore((s) => s.fetchConversations); + const setActive = useConversationStore((s) => s.setActiveConversation); + const currentUser = useAuthStore((s) => s.user); + const [showNew, setShowNew] = useState(false); + + useEffect(() => { + fetchConversations(); + }, [fetchConversations]); + + return ( +
+
+ [DIRECT MESSAGES] + +
+
+ {conversations.length === 0 && ( +

[no conversations]

+ )} + {conversations.map((conv) => { + const name = + conv.type === "group_dm" + ? conv.name || conv.members.map((m) => m.username).join(", ") + : otherMemberName(conv, currentUser?.id || ""); + return ( + + ); + })} +
+ {showNew && setShowNew(false)} />} +
+ ); +} diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx index 1db1bcd..38c6b6b 100644 --- a/web/src/components/CreateChannelModal.tsx +++ b/web/src/components/CreateChannelModal.tsx @@ -16,10 +16,21 @@ interface ChannelApiResponse { position: number; } +const SLOWMODE_OPTIONS = [ + { label: 'Off', value: 0 }, + { label: '5 seconds', value: 5 }, + { label: '10 seconds', value: 10 }, + { label: '30 seconds', value: 30 }, + { label: '1 minute', value: 60 }, + { label: '2 minutes', value: 120 }, + { label: '5 minutes', value: 300 }, +]; + export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) { const [name, setName] = useState(''); const [type, setType] = useState<'text' | 'voice'>('text'); const [category, setCategory] = useState('general'); + const [slowmodeSeconds, setSlowmodeSeconds] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -41,10 +52,11 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp setLoading(true); setError(null); try { - const result = await api.post(`/servers/${serverId}/channels`, { + const result = await api.post("/servers/" + serverId + "/channels", { name: name.trim(), type, category: category.trim() || 'general', + slowmode_seconds: slowmodeSeconds, }); const newChannel = { id: result.id, @@ -110,6 +122,18 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp className="terminal-input w-full" />
+
+ + +
{error &&
{error}
}
+ +
- + {dmMode ? : }
@@ -124,7 +144,7 @@ export function Layout() {
- + {!dmMode && }
TERM v1.0 diff --git a/web/src/components/MemberContextMenu.tsx b/web/src/components/MemberContextMenu.tsx new file mode 100644 index 0000000..4aaffa1 --- /dev/null +++ b/web/src/components/MemberContextMenu.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; + +interface MemberContextMenuProps { + memberId: string; + username: string; + canKick: boolean; + canBan: boolean; + canMute: boolean; + onMessage?: () => void; + onKick?: (reason: string) => void; + onBan?: (reason: string) => void; + onMute?: (duration: string, reason: string) => void; + onClose: () => void; +} + +export function MemberContextMenu({ + username, + canKick, + canBan, + canMute, + onMessage, + onKick, + onBan, + onMute, + onClose, +}: MemberContextMenuProps) { + const [mode, setMode] = useState<"menu" | "kick" | "ban" | "mute">("menu"); + const [reason, setReason] = useState(""); + const [duration, setDuration] = useState("1h"); + + if (mode === "kick") { + return ( +
+
KICK {username.toUpperCase()}
+ setReason(e.target.value)} + placeholder="reason (optional)" + className="terminal-input w-full mb-2" + /> +
+ + +
+
+ ); + } + + if (mode === "ban") { + return ( +
+
BAN {username.toUpperCase()}
+ setReason(e.target.value)} + placeholder="reason (optional)" + className="terminal-input w-full mb-2" + /> +
+ + +
+
+ ); + } + + if (mode === "mute") { + return ( +
+
MUTE {username.toUpperCase()}
+ + setReason(e.target.value)} + placeholder="reason (optional)" + className="terminal-input w-full mb-2" + /> +
+ + +
+
+ ); + } + + return ( +
+ + {canKick && ( + + )} + {canMute && ( + + )} + {canBan && ( + + )} +
+ +
+ ); +} diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx index dec161e..590bc7d 100644 --- a/web/src/components/MemberList.tsx +++ b/web/src/components/MemberList.tsx @@ -1,8 +1,11 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useServerStore } from "../stores/server.ts"; +import { useAuthStore } from "../stores/auth.ts"; import { useMemberStore, type Member } from "../stores/member.ts"; import { usePresenceStore } from "../stores/presence.ts"; import type { UserStatus } from "../stores/auth.ts"; +import { MemberContextMenu } from "./MemberContextMenu.tsx"; +import { useConversationStore } from "../stores/conversation.ts"; function statusIcon(status: UserStatus): string { switch (status) { @@ -46,13 +49,63 @@ function usernameColor(status: UserStatus): string { function MemberRow({ member }: { member: Member }) { const presence = usePresenceStore((s) => s.presences[member.id]); const status = presence?.status ?? member.status; + const [menuOpen, setMenuOpen] = useState(false); + const currentUser = useAuthStore((s) => s.user); + const userPerms = useServerStore((s) => s.userPermissions); + const activeServerId = useServerStore((s) => s.activeServerId); + const createConversation = useConversationStore((s) => s.createConversation); + + const canKick = userPerms?.kick_members ?? false; + const canBan = userPerms?.ban_members ?? false; + const canMute = userPerms?.mute_members ?? false; + const isSelf = currentUser?.id === member.id; return ( -
- {statusIcon(status)} - - {member.display_name || member.username} - +
+ + {menuOpen && !isSelf && activeServerId && ( + { + createConversation([member.id]); + setMenuOpen(false); + }} + onKick={(_reason) => { + fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" }) + .then(() => setMenuOpen(false)); + }} + onBan={(reason) => { + fetch(`/api/v1/servers/${activeServerId}/bans`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: member.id, reason }), + }).then(() => setMenuOpen(false)); + }} + onMute={(duration, reason) => { + fetch(`/api/v1/servers/${activeServerId}/mutes`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: member.id, duration, reason }), + }).then(() => setMenuOpen(false)); + }} + onClose={() => setMenuOpen(false)} + /> + )}
); } diff --git a/web/src/components/MessageSearch.tsx b/web/src/components/MessageSearch.tsx new file mode 100644 index 0000000..befdc9c --- /dev/null +++ b/web/src/components/MessageSearch.tsx @@ -0,0 +1,99 @@ +import { useState, useRef, useEffect } from "react"; +import { useMessageStore } from "../stores/message.ts"; + +interface MessageSearchProps { + channelId: string; + channelName: string; + onClose: () => void; + onJump?: (messageId: string) => void; +} + +export function MessageSearch({ channelId, channelName, onClose, onJump }: MessageSearchProps) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const results = useMessageStore((s) => s.searchResultsByChannel[channelId] || []); + const searchMessages = useMessageStore((s) => s.searchMessages); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const t = setTimeout(() => { + if (query.trim()) { + searchMessages(channelId, query.trim()); + setSelectedIndex(0); + } + }, 200); + return () => clearTimeout(t); + }, [query, channelId, searchMessages]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (results.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => (i + 1) % results.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => (i - 1 + results.length) % results.length); + } else if (e.key === "Enter") { + e.preventDefault(); + const msg = results[selectedIndex]; + if (msg && onJump) { + onJump(msg.id); + } + onClose(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [results, selectedIndex, onClose, onJump]); + + return ( +
+
+ SEARCH #{channelName.toUpperCase()} + +
+
+ setQuery(e.target.value)} + placeholder="search messages..." + className="terminal-input w-full text-sm" + /> +
+
+ {results.length === 0 && query.trim() && ( +
[no results]
+ )} + {results.map((msg, idx) => ( + + ))} +
+
+ ); +} diff --git a/web/src/components/NewConversationModal.tsx b/web/src/components/NewConversationModal.tsx new file mode 100644 index 0000000..1df4063 --- /dev/null +++ b/web/src/components/NewConversationModal.tsx @@ -0,0 +1,87 @@ +import { useState, useMemo } from "react"; +import { useConversationStore } from "../stores/conversation.ts"; +import { useMemberStore } from "../stores/member.ts"; +import { useServerStore } from "../stores/server.ts"; +import { useAuthStore } from "../stores/auth.ts"; + +interface NewConversationModalProps { + onClose: () => void; +} + +export function NewConversationModal({ onClose }: NewConversationModalProps) { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState([]); + const createConversation = useConversationStore((s) => s.createConversation); + const activeServerId = useServerStore((s) => s.activeServerId); + const membersByServer = useMemberStore((s) => s.membersByServer); + const currentUserId = useAuthStore((s) => s.user?.id); + const members = activeServerId ? membersByServer[activeServerId] || [] : []; + + const filtered = useMemo(() => { + const q = query.toLowerCase(); + return members.filter( + (m) => + m.id !== currentUserId && + (m.username.toLowerCase().includes(q) || + (m.display_name || "").toLowerCase().includes(q)), + ); + }, [members, query, currentUserId]); + + const toggle = (id: string) => { + setSelected((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const handleCreate = async () => { + if (selected.length === 0) return; + await createConversation(selected); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > +
+ NEW DIRECT MESSAGE + +
+ setQuery(e.target.value)} + placeholder="search members..." + className="terminal-input w-full mb-3 text-sm" + /> +
+ {filtered.map((m) => ( + + ))} + {filtered.length === 0 && ( +

[no members found]

+ )} +
+ +
+
+ ); +} diff --git a/web/src/components/ServerBar.tsx b/web/src/components/ServerBar.tsx index f4ae24e..9c36995 100644 --- a/web/src/components/ServerBar.tsx +++ b/web/src/components/ServerBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useServerStore } from '../stores/server.ts'; import { useChannelStore } from '../stores/channel.ts'; +import { useLayoutStore } from '../stores/layout.ts'; import { CreateServerModal } from './CreateServerModal.tsx'; import { JoinServerModal } from './JoinServerModal.tsx'; @@ -18,6 +19,7 @@ export function ServerBar() { const fetchServers = useServerStore((state) => state.fetchServers); const setActiveServer = useServerStore((state) => state.setActiveServer); const setActiveChannel = useChannelStore((state) => state.setActiveChannel); + const { isDM, setDM } = useLayoutStore(); const [showCreate, setShowCreate] = useState(false); const [showJoin, setShowJoin] = useState(false); @@ -28,26 +30,43 @@ export function ServerBar() { const handleSelect = (id: string) => { setActiveServer(id); setActiveChannel(null); + setDM(false); + }; + + const handleDM = () => { + setActiveServer(null); + setActiveChannel(null); + setDM(true); }; return ( <>
+ +
{servers.map((server) => ( ))} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 04a218e..410569d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,12 +1,17 @@ const API_BASE = '/api/v1'; +export interface ApiError extends Error { + status?: number; + retryAfter?: number; +} + async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = {}; if (body !== undefined) { headers['Content-Type'] = 'application/json'; } - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetch(API_BASE + path, { method, headers, credentials: 'include', @@ -14,7 +19,8 @@ async function request(method: string, path: string, body?: unknown): Promise }); if (!response.ok) { - let errorMessage = `Request failed: ${response.status}`; + let errorMessage = 'Request failed: ' + response.status; + let retryAfter: number | undefined; try { const errorData = await response.json(); if (typeof errorData?.message === 'string') { @@ -22,10 +28,19 @@ async function request(method: string, path: string, body?: unknown): Promise } else if (typeof errorData?.error === 'string') { errorMessage = errorData.error; } + if (response.status === 429 && typeof errorData?.retry_after === 'number') { + retryAfter = errorData.retry_after; + } } catch { // ignore parse error } - throw new Error(errorMessage); + const err = new Error(errorMessage) as ApiError; + err.status = response.status; + if (response.status === 429) { + const ra = response.headers.get('Retry-After'); + err.retryAfter = retryAfter ?? (ra ? parseInt(ra, 10) : undefined); + } + throw err; } if (response.status === 204) { diff --git a/web/src/lib/usePermissions.ts b/web/src/lib/usePermissions.ts new file mode 100644 index 0000000..39ff228 --- /dev/null +++ b/web/src/lib/usePermissions.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; +import { useServerStore } from '../stores/server.ts'; +import { useAuthStore } from '../stores/auth.ts'; +import { useRoleStore } from '../stores/role.ts'; + +const PERMS = { + VIEW_CHANNEL: 1, + SEND_MESSAGES: 2, + MANAGE_MESSAGES: 4, + KICK_MEMBERS: 8, + BAN_MEMBERS: 16, + MANAGE_SERVER: 32, + MANAGE_CHANNELS: 64, + ADMINISTRATOR: 128, + CONNECT_VOICE: 256, + SPEAK_VOICE: 512, + SHARE_SCREEN: 1024, +} as const; + +export { PERMS }; + +export function usePermissions(serverId: string | null) { + const user = useAuthStore((s) => s.user); + const servers = useServerStore((s) => s.servers); + const roles = useRoleStore((s) => s.roles); + + const server = serverId ? servers.find((s) => s.id === serverId) : null; + const isOwner = Boolean(server && user && server.ownerId === user.id); + + const memberRoles = roles.filter((r) => r.server_id === serverId); + + const has = useCallback( + (flag: number) => { + if (!serverId || !user) return false; + if (isOwner) return true; + const effective = memberRoles.reduce((acc, r) => acc | r.permissions, 0); + if ((effective & PERMS.ADMINISTRATOR) !== 0) return true; + return (effective & flag) === flag; + }, + [serverId, user, isOwner, memberRoles], + ); + + return { + canKick: has(PERMS.KICK_MEMBERS), + canBan: has(PERMS.BAN_MEMBERS), + canMute: has(PERMS.MANAGE_MESSAGES), + canManageChannels: has(PERMS.MANAGE_CHANNELS), + isOwner, + }; +} diff --git a/web/src/stores/conversation.ts b/web/src/stores/conversation.ts new file mode 100644 index 0000000..b1b4825 --- /dev/null +++ b/web/src/stores/conversation.ts @@ -0,0 +1,120 @@ +import { create } from "zustand"; +import { api } from "../lib/api.ts"; + +export interface ConversationMember { + id: string; + username: string; + display_name: string; + avatar: string; +} + +export interface Conversation { + id: string; + type: "dm" | "group_dm"; + name: string; + members: ConversationMember[]; + created_at: string; +} + +export interface ConversationMessage { + id: string; + conversation_id: string; + author_id: string; + author_username: string; + author_display_name: string | null; + content: string; + created_at: string; + edited_at: string | null; +} + +interface ConversationState { + conversations: Conversation[]; + activeConversationId: string | null; + messagesByConversation: Record; + isLoading: boolean; + error: string | null; + fetchConversations: () => Promise; + createConversation: (userIds: string[]) => Promise; + setActiveConversation: (id: string | null) => void; + fetchMessages: (conversationId: string, before?: string) => Promise; + sendMessage: (conversationId: string, content: string) => Promise; + addMessage: (message: ConversationMessage) => void; +} + +export const useConversationStore = create((set, _get) => ({ + conversations: [], + activeConversationId: null, + messagesByConversation: {}, + isLoading: false, + error: null, + + fetchConversations: async () => { + try { + const conversations = await api.get("/conversations"); + set({ conversations: Array.isArray(conversations) ? conversations : [] }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : "Failed to fetch conversations", + }); + } + }, + + createConversation: async (userIds) => { + const conversation = await api.post("/conversations", { user_ids: userIds }); + set((state) => ({ + conversations: [conversation, ...state.conversations], + activeConversationId: conversation.id, + })); + return conversation; + }, + + setActiveConversation: (id) => set({ activeConversationId: id }), + + fetchMessages: async (conversationId, before) => { + set({ isLoading: true }); + try { + const params = before ? "?before=" + encodeURIComponent(before) : ""; + const messages = await api.get( + `/conversations/${conversationId}/messages${params}`, + ); + set((state) => ({ + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: Array.isArray(messages) ? messages : [], + }, + isLoading: false, + })); + } catch (error) { + set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" }); + } + }, + + sendMessage: async (conversationId, content) => { + const message = await api.post( + `/conversations/${conversationId}/messages`, + { content }, + ); + set((state) => ({ + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: [ + ...(state.messagesByConversation[conversationId] || []), + message, + ], + }, + })); + return message; + }, + + addMessage: (message) => { + set((state) => ({ + messagesByConversation: { + ...state.messagesByConversation, + [message.conversation_id]: [ + ...(state.messagesByConversation[message.conversation_id] || []), + message, + ], + }, + })); + }, +})); diff --git a/web/src/stores/layout.ts b/web/src/stores/layout.ts new file mode 100644 index 0000000..df9f13b --- /dev/null +++ b/web/src/stores/layout.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand'; + +interface LayoutState { + isDM: boolean; + setDM: (value: boolean) => void; +} + +export const useLayoutStore = create((set) => ({ + isDM: false, + setDM: (value) => set({ isDM: value }), +})); diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts index 031dc6c..6d29021 100644 --- a/web/src/stores/message.ts +++ b/web/src/stores/message.ts @@ -1,6 +1,15 @@ import { create } from "zustand"; import { api } from "../lib/api.ts"; +export interface MessageEmbed { + id?: string; + url: string; + title?: string; + description?: string; + image_url?: string; + site_name?: string; +} + export interface Message { id: string; channel_id: string; @@ -8,16 +17,24 @@ export interface Message { author_username: string; author_display_name: string | null; content: string; + reply_to?: string | null; + embeds?: MessageEmbed[]; created_at: string; edited_at: string | null; } +export interface SearchResultMessage extends Message { + channel_name?: string; +} + interface MessageState { messagesByChannel: Record; + searchResultsByChannel: Record; isLoading: boolean; error: string | null; fetchMessages: (channelId: string, before?: string) => Promise; - sendMessage: (channelId: string, content: string) => Promise; + sendMessage: (channelId: string, content: string, replyTo?: string) => Promise; + searchMessages: (channelId: string, query: string) => Promise; addMessage: (message: Message) => void; updateMessage: (message: Message) => void; removeMessage: (channelId: string, messageId: string) => void; @@ -25,6 +42,7 @@ interface MessageState { export const useMessageStore = create((set) => ({ messagesByChannel: {}, + searchResultsByChannel: {}, isLoading: false, error: null, @@ -53,10 +71,25 @@ export const useMessageStore = create((set) => ({ } }, - sendMessage: async (channelId, content) => { + searchMessages: async (channelId, query) => { + const results = await api.get( + `/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`, + ); + set((state) => ({ + searchResultsByChannel: { + ...state.searchResultsByChannel, + [channelId]: Array.isArray(results) ? results : [], + }, + })); + return Array.isArray(results) ? results : []; + }, + + sendMessage: async (channelId, content, replyTo) => { + const body: { content: string; reply_to?: string } = { content }; + if (replyTo) body.reply_to = replyTo; const message = await api.post( `/channels/${channelId}/messages`, - { content }, + body, ); set((state) => { const list = state.messagesByChannel[channelId] || []; diff --git a/web/src/stores/moderation.ts b/web/src/stores/moderation.ts new file mode 100644 index 0000000..947484c --- /dev/null +++ b/web/src/stores/moderation.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import { api } from '../lib/api.ts'; + +export interface ModerationState { + isLoading: boolean; + error: string | null; + kickMember: (serverId: string, userId: string, reason?: string) => Promise; + banMember: (serverId: string, userId: string, reason?: string) => Promise; + unbanMember: (serverId: string, userId: string) => Promise; + muteMember: (serverId: string, userId: string, durationSeconds: number) => Promise; + clearError: () => void; +} + +export const useModerationStore = create((set) => ({ + isLoading: false, + error: null, + + kickMember: async (serverId, userId, reason) => { + set({ isLoading: true, error: null }); + try { + await api.post(`/servers/${serverId}/members/${userId}/kick`, { reason }); + set({ isLoading: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to kick member'; + set({ isLoading: false, error: message }); + throw error; + } + }, + + banMember: async (serverId, userId, reason) => { + set({ isLoading: true, error: null }); + try { + await api.post(`/servers/${serverId}/members/${userId}/ban`, { reason }); + set({ isLoading: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to ban member'; + set({ isLoading: false, error: message }); + throw error; + } + }, + + unbanMember: async (serverId, userId) => { + set({ isLoading: true, error: null }); + try { + await api.post(`/servers/${serverId}/members/${userId}/unban`, {}); + set({ isLoading: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to unban member'; + set({ isLoading: false, error: message }); + throw error; + } + }, + + muteMember: async (serverId, userId, durationSeconds) => { + set({ isLoading: true, error: null }); + try { + await api.post(`/servers/${serverId}/members/${userId}/mute`, { duration_seconds: durationSeconds }); + set({ isLoading: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to mute member'; + set({ isLoading: false, error: message }); + throw error; + } + }, + + clearError: () => set({ error: null }), +})); diff --git a/web/src/stores/server.ts b/web/src/stores/server.ts index 563ff3b..466bc38 100644 --- a/web/src/stores/server.ts +++ b/web/src/stores/server.ts @@ -12,6 +12,14 @@ export interface Server { interface ServerState { servers: Server[]; activeServerId: string | null; + userPermissions: { + kick_members: boolean; + ban_members: boolean; + mute_members: boolean; + manage_channels: boolean; + manage_server: boolean; + administrator: boolean; + }; isLoading: boolean; error: string | null; fetchServers: () => Promise; @@ -19,11 +27,20 @@ interface ServerState { addServer: (server: Server) => void; updateServer: (server: Server) => void; removeServer: (id: string) => void; + setUserPermissions: (perms: Partial) => void; } export const useServerStore = create((set) => ({ servers: [], activeServerId: null, + userPermissions: { + kick_members: false, + ban_members: false, + mute_members: false, + manage_channels: false, + manage_server: false, + administrator: false, + }, isLoading: false, error: null, @@ -57,4 +74,9 @@ export const useServerStore = create((set) => ({ servers: state.servers.filter((s) => s.id !== id), activeServerId: state.activeServerId === id ? null : state.activeServerId, })), + + setUserPermissions: (perms) => + set((state) => ({ + userPermissions: { ...state.userPermissions, ...perms }, + })), })); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index a18cfde..1d14379 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.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/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.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/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file