Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/marchat.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Prefer this repo (`go.mod`, `ARCHITECTURE.md`, `PROTOCOL.md`, skills) as source
- **Startup**: validated before serve - at least one admin, non-empty admin key, valid listen port; **admin names** trimmed, lowercased, case-insensitive **dedupe** (`cmd/server`, extracted validation helpers)
- **Hub**: per-channel routing, DMs, typing/read receipts/reactions; outbound client messages channel-stamped from membership (`stampClientChannel`); outbound `sender` stamped from authenticated session (`stampSenderTimedOutbound`); **reserved usernames** so handshake cannot double-book a name before registration
- **WebSocket**: **serialized writes** per connection (avoid concurrent write + control-frame panics)
- **SQLite**: **WAL** enabled when backend is SQLite; in-process `:backup` uses **VACUUM INTO** (SQLite only; quote paths safely when SQL embeds paths). Postgres/MySQL use native backup tools.
- **SQLite**: WAL and related pragmas via DSN on every connection (`_busy_timeout`, `_journal_mode=WAL`, …) plus `SetMaxOpenConns(1)` / `SetMaxIdleConns(1)` in `InitDB` - not one-shot `PRAGMA` `Exec` with the default pool; in-process `:backup` uses **VACUUM INTO** (SQLite only; quote paths safely when SQL embeds paths). Postgres/MySQL use native backup tools.
- **Web admin** (`server/admin_web.go`, `server/admin_web.html`): session cookies; **`MARCHAT_SESSION_SECRET`** preferred, **`MARCHAT_JWT_SECRET`** deprecated alias; `config.GenerateSessionSecret()` when unset; **login rate limiting** per IP; **CSRF** on mutating routes
- **Interactive server config** (`server/config_ui.go`): can generate session secret when saving config
- **Health**: metrics and health HTTP endpoints (`server/health.go`)
Expand Down
3 changes: 2 additions & 1 deletion .cursor/skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ For Cursor, dependencies, or platform behavior not defined in this repo, verify

Update domain skills when shipped behavior changes. Recent fixes on `main` (or in flight):

- Reconnect backoff advances on failure (not reset each `Init()`); channel stamping on server outbound messages
- SQLite `InitDB`: DSN per-connection pragmas (`busy_timeout`, WAL) + `MaxOpenConns(1)` / `MaxIdleConns(1)`; do not one-shot `PRAGMA` with the default pool ([#118](https://github.com/Cod-e-Codes/marchat/issues/118))
- Kick/ban self-target rejection and online-only kick (`ErrKickNotConnected` for offline targets; `BanUser` offline-capable)
- Client transcript notices: negative `message_id` classified by content; scoped to active channel
- URL click: OSC 8 hyperlinks on wrapped segments (Lip Gloss v2); manual click fallback remains unreliable for wrapped long URLs; copy/paste when needed ([#103](https://github.com/Cod-e-Codes/marchat/issues/103))
- Charm v2: `charm.land/*/v2`, `tea.View` + `KeyPressMsg`, overlay scroll/input routing in `scroll_input.go`
Expand Down
12 changes: 9 additions & 3 deletions .cursor/skills/database-marchat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ Runtime backend via `MARCHAT_DB_PATH`: SQLite (default), PostgreSQL, or MySQL. D

- Parameterized queries only; no string-concatenated user input.
- Every schema or query change must work on all three dialects (or use `db_dialect.go` helpers).
- SQLite: WAL when backend is SQLite; quote paths safely in `VACUUM INTO` and similar.
- SQLite (`InitDB` only):
- Put connection pragmas in the DSN so every pooled connection gets them (`_busy_timeout=5000`, `_journal_mode=WAL`, `_synchronous=NORMAL`, plus `_pragma` for cache/temp). Join with `?` or `&` if the path already has a query (`appendSQLiteDSNPragmas`).
- After `Ping`, set `SetMaxOpenConns(1)` and `SetMaxIdleConns(1)`. Do **not** leave the default multi-connection `database/sql` pool on SQLite.
- Do **not** rely on one-shot `Exec("PRAGMA ...")` after open for settings that must stick on every connection (that was the #118 `SQLITE_BUSY` failure mode).
- Verify after open: `busy_timeout > 0`; for file-backed DBs, `journal_mode` is `wal`. In-memory (`:memory:` / `mode=memory`) requires busy_timeout only.
- Quote paths safely in `VACUUM INTO` and similar.
- Postgres/MySQL: leave pool defaults alone (do not force `MaxOpenConns(1)`).
- MySQL: DSN via `mysql:` or `mysql://`; `mysql.Config` with `parseTime=true`; indexed text rules for search.
- Postgres: boolean columns need dialect boolean literals, not `= 0` / `= 1`.

Expand All @@ -32,8 +38,8 @@ Include messages plus durable state: reactions, read receipts, `user_message_sta

| Level | Where |
|-------|--------|
| Unit / integration | In-memory or temp SQLite in `server/*_test.go` |
| CI smoke | `server/db_ci_smoke_test.go` with `MARCHAT_CI_POSTGRES_URL`, `MARCHAT_CI_MYSQL_URL` |
| Unit / integration | In-memory or temp SQLite in `server/*_test.go` (`db_test.go` covers DSN join, file WAL, `:memory:`, concurrent inserts) |
| CI smoke | `server/db_ci_smoke_test.go` with `MARCHAT_CI_POSTGRES_URL`, `MARCHAT_CI_MYSQL_URL` (also asserts pool is not forced to 1) |
| Handlers | Visible replay SQL (`GetRecentMessagesForUser`), search, pin toggle |

Locally, CI smoke tests skip without env vars. See `testing-marchat` skill.
Expand Down
1 change: 1 addition & 0 deletions .cursor/skills/debugging-marchat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Implementation: `internal/doctor/`. DB dialect and DSN shape checks live there a
| Postgres boolean errors | Dialect boolean helpers in `server/db_dialect.go` (`:search`, pin toggle) |
| MySQL time parsing | `mysql.Config` with `parseTime=true` in `InitDB` |
| SQLite path vs remote DSN | `MARCHAT_DB_PATH`; `mysql:` / `postgres:` prefixes for driver detection |
| SQLite `SQLITE_BUSY` / missing messages under load | Confirm `InitDB` DSN pragmas (`_busy_timeout`, WAL) and `MaxOpenConns(1)`; not one-shot `PRAGMA` alone ([#118](https://github.com/Cod-e-Codes/marchat/issues/118)) |
| Plugin disable race | `StopPlugin` waits for stdout/stderr readers (`plugin/host`) |
| Rate limit | `server/loadverify_ratelimit_test.go` constants match `client.go` read pump |

Expand Down
6 changes: 3 additions & 3 deletions .cursor/skills/server-marchat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ App entry: `cmd/server/main.go`. Library: `server/` (hub, client, handlers, db,

- Per-channel routing, DMs, typing, read receipts, reactions.
- Outbound client messages are channel-stamped from hub membership (`stampClientChannel`); client-supplied `channel` values are ignored for routing.
- All outbound/persist paths stamp `sender` from the authenticated session (`stampSenderTimedOutbound`); NUL bytes in persistable `content` are rejected before insert.
- All outbound/persist paths stamp `sender` from the authenticated session (`stampSenderTimedOutbound`); NUL bytes in persistable `content` are rejected before insert; empty or whitespace-only plaintext on `text` / `dm` / `edit` is rejected when `encrypted` is false (encrypted opaque ciphertext is never treated as empty).
- Reserved usernames during handshake (no double-book before registration).
- Serialized writes per connection (`client.go`).
- File uploads: `SetReadLimit` uses `websocketReadLimit` (max of policy `fileMessageReadLimit` wire size and a **32 MiB** DoS ceiling) so modest oversize is fully read; declared/payload checks send a System reply and `continue`. `ErrReadLimit` (above the ceiling) logs rejection only - gorilla already sent empty close **1009**, so a System enqueue cannot flush.
Expand All @@ -32,8 +32,8 @@ App entry: `cmd/server/main.go`. Library: `server/` (hub, client, handlers, db,

## Admin

- TUI: `admin_panel.go`, `config_ui.go` (Charm v2: `tea.View`, `KeyPressMsg`, bubbles setters). Admin panel enables `MouseModeCellMotion` and routes `MouseWheelMsg` for scrollable tabs and user/plugin tables.
- Web: `admin_web.go`, `admin_web.html`; `MARCHAT_SESSION_SECRET` (preferred), `MARCHAT_JWT_SECRET` deprecated; CSRF on mutating routes; login rate limit per IP.
- TUI: `admin_panel.go`, `config_ui.go` (Charm v2: `tea.View`, `KeyPressMsg`, bubbles setters). Admin panel enables `MouseModeCellMotion` and routes `MouseWheelMsg` for scrollable tabs and user/plugin tables. Kick/ban cmds claim success only when hub `KickUser`/`BanUser` return nil (self-target, not-connected kick, and permanently-banned kick errors map to failed action messages). `KickUser` is online-only; offline kicks return `ErrKickNotConnected`.
- Web: `admin_web.go`, `admin_web.html`; `MARCHAT_SESSION_SECRET` (preferred), `MARCHAT_JWT_SECRET` deprecated; CSRF on mutating routes; login rate limit per IP. User kick/ban actions return `success: false` with a message when the hub rejects the target.
- Trusted proxies: `MARCHAT_TRUSTED_PROXIES` for forwarded client IP.

## Security
Expand Down
16 changes: 9 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The server is a standalone HTTP/WebSocket server application that provides real-
#### Core Structures

- **`Hub`**: Central message routing system managing client connections, message broadcasting, channel management, and user state; tracks reserved usernames so handshake cannot double-book the same name before a client is registered. All sends to `client.send` use non-blocking `select/default` to prevent deadlocks when a client's write buffer is full; stalled clients are dropped or the message is logged and skipped. **Text** messages fan out to plugins in a **separate goroutine** so plugin IPC never blocks the hub’s broadcast loop.
- **`Client`**: Individual WebSocket connection handler with read/write pumps and command processing. The `writePump` goroutine is started **before** history replay on connect so the send channel always has a consumer. Outbound client messages are channel-stamped from hub membership (`stampClientChannel`) so spoofed `channel` values cannot cross rooms; `sender` is stamped from the authenticated session (`stampSenderTimedOutbound`) on persist and broadcast paths. NUL bytes in persistable `content` are rejected before insert (Postgres rejects NUL in TEXT; SQLite accepts it). Failed inserts reply to the sender and are not broadcast. `handleCommand` sends `Unknown command` to non-admins for unrecognized `:` tokens; built-in admin-only commands still return an admin-privilege notice. Admins get `Unknown command` for unrecognized built-ins.
- **`Client`**: Individual WebSocket connection handler with read/write pumps and command processing. The `writePump` goroutine is started **before** history replay on connect so the send channel always has a consumer. Outbound client messages are channel-stamped from hub membership (`stampClientChannel`) so spoofed `channel` values cannot cross rooms; `sender` is stamped from the authenticated session (`stampSenderTimedOutbound`) on persist and broadcast paths. NUL bytes in persistable `content` are rejected before insert (Postgres rejects NUL in TEXT; SQLite accepts it). Empty or whitespace-only plaintext on `text`, `dm`, and `edit` is rejected when `encrypted` is false (System reply; no insert or broadcast); when `encrypted` is true the server treats `content` as opaque and does not apply the empty-plaintext check. Failed inserts reply to the sender and are not broadcast. `handleCommand` sends `Unknown command` to non-admins for unrecognized `:` tokens; built-in admin-only commands still return an admin-privilege notice. Admins get `Unknown command` for unrecognized built-ins.
- **`AdminPanel`**: Terminal-based administrative interface for server management
- **`WebAdminServer`**: Web-based administrative interface with session authentication
- **`HealthChecker`**: System health monitoring with metrics collection
Expand All @@ -103,7 +103,7 @@ The server is a standalone HTTP/WebSocket server application that provides real-
- Direct message routing between specific users
- Message editing, deletion, pinning, and search
- Typing indicator, reaction, and read receipt broadcasting (channel-scoped when `channel` is set after stamping)
- User management including ban, kick, and allow operations (ban/kick state is committed under `banMutex`, then the lock is released before the actual disconnect to avoid holding the mutex across a channel send)
- User management including ban, kick, and allow operations (ban/kick state is committed under `banMutex`, then the lock is released before the actual disconnect to avoid holding the mutex across a channel send). `KickUser` and `BanUser` return errors; self-targets are rejected case-insensitively before any ban state is written, and callers (chat commands, admin TUI, web admin) claim success only when `err == nil`. `KickUser` is online-only (disconnect plus 24h temporary ban when the target has an active connection); offline moderation uses `BanUser` / `:ban`
- Plugin command execution and management
- Database backup via `:backup` and admin panels: **SQLite only** (`VACUUM INTO` with quoted paths). Postgres and MySQL deployments receive a clear error directing operators to native backup tools for `MARCHAT_DB_PATH`.
- System metrics collection and health monitoring
Expand Down Expand Up @@ -261,7 +261,7 @@ Client: WebSocket Receive → Decrypt → Display
- MySQL DSN (`mysql:` / `mysql://`)
- Schema creation and upsert/insert-ignore SQL are dialect-aware; message query helpers in `server/db_dialect.go` emit Postgres `TRUE`/`FALSE` or SQLite/MySQL `1`/`0` for boolean columns as needed.
- Placeholder rebinding keeps shared query callsites portable across backends.
- SQLite-specific optimizations (for example WAL mode) are applied only when the selected backend is SQLite.
- SQLite-specific optimizations are applied only when the selected backend is SQLite: per-connection DSN pragmas (`_busy_timeout`, `_journal_mode=WAL`, `_synchronous`, cache/temp store) plus `SetMaxOpenConns(1)` / `SetMaxIdleConns(1)`. Do not rely on one-shot `PRAGMA` `Exec` after `Open` for settings that must stick on every pooled connection.
- Durable state includes:
- message history
- reactions
Expand Down Expand Up @@ -372,14 +372,15 @@ CREATE TABLE read_receipts (
### Key Features

- **Backend Selection**: `MARCHAT_DB_PATH` chooses SQLite/PostgreSQL/MySQL at runtime
- **WAL Mode (SQLite only)**: Write-Ahead Logging for better concurrency and crash recovery when SQLite is selected
- **WAL Mode (SQLite only)**: Write-Ahead Logging via DSN `_journal_mode=WAL` on every connection (file-backed DBs); verified after open. In-memory DSNs require `busy_timeout` only (WAL may not stick).
- **SQLite pool**: `MaxOpenConns(1)` and `MaxIdleConns(1)` so writers share one connection; Postgres/MySQL keep driver/pool defaults
- **SQLite Database Files**: `marchat.db` (main), `marchat.db-wal` (write-ahead log), `marchat.db-shm` (shared memory)
- **Message ID Tracking**: Sequential message IDs for user state management
- **Encryption Support**: Binary storage for encrypted message data
- **Performance Indexes**: Optimized queries for message retrieval and user state
- **Message Cap**: Automatic cleanup maintaining 1000 most recent messages
- **Ban History**: Comprehensive tracking of user moderation actions
- **Performance Tuning**: Backend-aware optimizations (SQLite pragmas when SQLite is selected)
- **Performance Tuning**: SQLite DSN per-connection pragmas and single-conn pool when SQLite is selected; Postgres/MySQL unchanged

## Administrative Interfaces

Expand Down Expand Up @@ -455,12 +456,13 @@ The web-based interface (`admin_web.html`, embedded via `go:embed`) provides the

### Database Optimization

- **WAL Mode (SQLite only)**: Write-Ahead Logging enabled for improved concurrency and performance
- **WAL Mode (SQLite only)**: Write-Ahead Logging via per-connection DSN pragmas (not one-shot `PRAGMA` after open)
- **SQLite single-conn pool**: `MaxOpenConns(1)` / `MaxIdleConns(1)` with `_busy_timeout=5000` to avoid `SQLITE_BUSY` under concurrent WebSocket writers
- **Indexed Queries**: Performance indexes on frequently queried columns
- **Batch Operations**: Efficient bulk message operations
- **Connection Reuse**: Persistent database connections
- **Query Optimization**: Prepared statements for common operations
- **Performance Tuning**: SQLite-specific pragmas are applied only on SQLite; Postgres/MySQL use driver/backend defaults
- **Performance Tuning**: SQLite DSN pragmas + single-conn pool only on SQLite; Postgres/MySQL use driver/backend defaults
- **Backup Considerations**: SQLite WAL mode creates additional files; backups may miss recent uncommitted data if taken while server is running

## Development Patterns
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Narrative notes by release. Per-file binaries and assets: [GitHub releases](http

On **`main`** only; not part of the latest tagged release until you tag and publish. Compare against the current tag on [GitHub releases](https://github.com/Cod-e-Codes/marchat/releases).

- **Server**: **Fix:** `:kick` / `:ban` (and admin TUI / web user actions) reject self-targets case-insensitively and return clear errors instead of disconnecting the admin and writing a 24h ban; `KickUser` / `BanUser` return errors so callers claim success only on `nil`; kicking an already permanently banned user returns an error without claiming success ([#115](https://github.com/Cod-e-Codes/marchat/issues/115)).
- **Server**: **Fix:** `:kick` (and admin TUI / web kick actions) are online-only: `KickUser` requires an active WebSocket connection, disconnects the target, and applies a 24h temporary ban; offline or never-connected users return `ErrKickNotConnected` with no `tempKicks` entry or `ban_history` row; `:ban` / `BanUser` remain offline-capable ([#116](https://github.com/Cod-e-Codes/marchat/issues/116)).
- **Server**: **Fix:** reject empty or whitespace-only plaintext on `text`, `dm`, and `edit` when `encrypted` is false (System reply, no persist/broadcast); encrypted opaque `content` is not treated as empty ([#117](https://github.com/Cod-e-Codes/marchat/issues/117)).
- **Server**: **Fix:** SQLite `InitDB` applies `busy_timeout` / WAL / related pragmas via the DSN on every connection and sets `MaxOpenConns(1)` / `MaxIdleConns(1)`, so concurrent inserts no longer fail with `SQLITE_BUSY` from one-shot `PRAGMA` + the default `database/sql` pool ([#118](https://github.com/Cod-e-Codes/marchat/issues/118)).

## v1.3.4

**Released 2026-08-03.** Since **[v1.3.3](https://github.com/Cod-e-Codes/marchat/releases/tag/v1.3.3)**; compare [`v1.3.3...v1.3.4`](https://github.com/Cod-e-Codes/marchat/compare/v1.3.3...v1.3.4). Commits: **`git log v1.3.3..v1.3.4 --oneline`**.
Expand Down
3 changes: 2 additions & 1 deletion PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Optional fields (`recipient`, `reaction`, etc.) are omitted from JSON when unset
#### Fields

- `sender` (string): Username of the sender.
- `content` (string): Message text. Empty if type is `file`. For `search`, carries the query string.
- `content` (string): Message text. Empty if type is `file`. For `search`, carries the query string. For unencrypted `text`, `dm`, and `edit`, the server rejects empty or whitespace-only `content` with a private System `text` reply (connection stays open; nothing is persisted or broadcast). When `encrypted` is `true`, `content` is opaque ciphertext and is not checked for emptiness.
- `created_at` (string): RFC3339 timestamp.
- `type` (string): Core types include `"text"`, `"file"`, and `"admin_command"`. See [Extended Message Types](#extended-message-types) for additional values.
- `file` (object, optional): Present only when `type` is `"file"`.
Expand Down Expand Up @@ -175,6 +175,7 @@ The server stores and relays opaque `content` (and encrypted file blobs) without
- Broadcasts updated user list.
- On message send:
- Persists eligible messages to the configured SQL backend selected by `MARCHAT_DB_PATH` (SQLite path, PostgreSQL DSN, or MySQL DSN).
- Rejects unencrypted `text` / `dm` / `edit` with empty or whitespace-only `content` (System reply to sender; no persist or broadcast). Encrypted payloads are not emptiness-checked.
- Delivers to all connected clients **or** only to members of a channel when `channel` is non-empty and `sender` is not `System` (see [Channels](#channels)). Direct messages use a separate path (sender and recipient only).
- Reactions, read receipts, and last channel per user may be persisted server-side and replayed to reconnecting clients.
- DM unread counters and DM thread hide/archive state are client-side UI state in the reference TUI, not server protocol fields. The reference client stores this local state under its client config directory. Opening a DM thread marks that thread read immediately in the reference client.
Expand Down
Loading