Skip to content
Open
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
11 changes: 7 additions & 4 deletions .cursor/skills/database-marchat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ description: >-
paths:
- "server/db.go"
- "server/db_dialect.go"
- "server/migrate.go"
- "server/db_*_test.go"
- "server/migrate_test.go"
- "server/handlers.go"
- "server/message_state.go"
---
Expand Down Expand Up @@ -46,10 +48,11 @@ Locally, CI smoke tests skip without env vars. See `testing-marchat` skill.

## Schema change workflow

1. Update `CreateSchema` / migrations in `db.go` with dialect branches.
2. Add or extend `db_dialect_test.go` for new SQL fragments.
3. Run `go test ./server/...`.
4. Document env or migration notes in `ARCHITECTURE.md` / `CHANGELOG.md` if user-visible.
1. Add a new migration step in `server/migrate.go` (`applyMigrationV2`, etc.) and bump `currentSchemaVersion`; extend `verifySchema` when new required tables or columns ship. Prefer deterministic DDL for versions after the v1 baseline (avoid inspect-and-reconcile).
2. `MigrateSchema` runs ordered migrations, records `schema_version`, and verifies required tables (including `ban_history.expires_at`). SQLite/Postgres wrap each version in a transaction; MySQL cannot (DDL implicit commit) - document that when changing migrator behavior. `CreateSchema` in the same file is a thin `log.Fatal` wrapper for tests.
3. Add or extend `db_dialect_test.go` for new SQL fragments.
4. Run `go test ./server/...`.
5. Document env or migration notes in `ARCHITECTURE.md` / `CHANGELOG.md` if user-visible.

## Backup

Expand Down
2 changes: 2 additions & 0 deletions .cursor/skills/server-marchat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ App entry: `cmd/server/main.go`. Library: `server/` (hub, client, handlers, db,
## Hub and WebSocket

- Per-channel routing, DMs, typing, read receipts, reactions.
- Moderation: permanent bans and unexpired temp kicks load from `ban_history` on hub start (latest open row per user); writers close open rows before insert and persist before updating in-memory maps. See Hub mutex rules comment on `Hub` in `hub.go`.
- Inbound WebSocket messages: `readPump` rate-limits then `dispatchInbound` / typed handlers in `client_dispatch.go`.
- 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; 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).
Expand Down
28 changes: 12 additions & 16 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,28 @@ jobs:
- name: Test
run: go test -race ./...

- name: Lint (golangci-lint if available, else go vet)
- name: Lint (golangci-lint)
run: |
if go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 2>/dev/null; then
$(go env GOPATH)/bin/golangci-lint run ./...
else
go vet ./...
fi
# Bump from https://github.com/golangci/golangci-lint/releases
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
$(go env GOPATH)/bin/golangci-lint run ./...

- name: Govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
# Bump from https://pkg.go.dev/golang.org/x/vuln?tab=versions
go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
"$(go env GOPATH)/bin/govulncheck" -show verbose ./...

# Nested modules are not part of root `go test ./...` / `go build ./...` (separate go.mod).
- name: Nested Go modules (plugin/sdk, plugin/examples/echo)
run: |
set -euo pipefail
go install golang.org/x/vuln/cmd/govulncheck@latest
# Bump from https://pkg.go.dev/golang.org/x/vuln?tab=versions
go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
VULN="$(go env GOPATH)/bin/govulncheck"
# Bump from https://github.com/golangci/golangci-lint/releases
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
LINT="$(go env GOPATH)/bin/golangci-lint"
for dir in plugin/sdk plugin/examples/echo; do
echo "::group::$dir"
(
Expand All @@ -65,17 +68,10 @@ jobs:
go test -race ./...
go vet ./...
"$VULN" -show verbose ./...
"$LINT" run ./...
)
echo "::endgroup::"
done
LINT="$(go env GOPATH)/bin/golangci-lint"
if [ -x "$LINT" ]; then
for dir in plugin/sdk plugin/examples/echo; do
echo "::group::lint $dir"
(cd "$dir" && "$LINT" run ./...)
echo "::endgroup::"
done
fi

database-smoke:
runs-on: ubuntu-latest
Expand Down
23 changes: 23 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# golangci-lint v2 config for marchat.
# Pin bumps: https://github.com/golangci/golangci-lint/releases
# Schema: https://golangci-lint.run/docs/product/migration-guide/
version: "2"

linters:
# Keep CI focused: govet-equivalent plus staticcheck SA* (bugs), without
# default errcheck-on-Close or ST/QF style nits across the whole tree.
default: none
enable:
- govet
- ineffassign
- staticcheck
settings:
staticcheck:
checks:
- all
- "-ST*"
- "-QF*"

issues:
max-issues-per-linter: 0
max-same-issues: 0
15 changes: 13 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ See [PROTOCOL.md](PROTOCOL.md) for the full message format specification.

## Database Schema

DDL below uses the **SQLite** dialect for readability. PostgreSQL and MySQL variants differ in type names (`BIGSERIAL`/`BIGINT AUTO_INCREMENT` for IDs, `VARCHAR(191)` for indexed text on MySQL, `LONGTEXT`/`LONGBLOB` for large fields) and are generated by `CreateSchema` in `server/handlers.go`.
DDL below uses the **SQLite** dialect for readability. PostgreSQL and MySQL variants differ in type names (`BIGSERIAL`/`BIGINT AUTO_INCREMENT` for IDs, `VARCHAR(191)` for indexed text on MySQL, `LONGTEXT`/`LONGBLOB` for large fields) and are applied by versioned `MigrateSchema` in `server/migrate.go` (`schema_version`; `CreateSchema` is a thin fatal wrapper for tests). SQLite and PostgreSQL wrap each versioned migration in a transaction. MySQL DDL implicitly commits, so MySQL runs migration steps without a multi-statement transaction; `schema_version` is still recorded only after a successful apply. Future versions after the v1 baseline should be deterministic DDL steps, not inspect-and-reconcile passes.

### Tables

Expand Down Expand Up @@ -333,9 +333,20 @@ CREATE TABLE ban_history (
username TEXT NOT NULL,
banned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
unbanned_at DATETIME,
banned_by TEXT NOT NULL
banned_by TEXT NOT NULL,
expires_at DATETIME
);
```
Open rows (`unbanned_at` NULL) are the source of truth for active moderation across restart: writers close any open row for the username before inserting a new one (at most one open row). `expires_at` NULL means permanent ban; non-NULL is a temporary kick expiry. Hub start loads the latest open row per user (`ORDER BY id DESC`).

#### `schema_version`
```sql
CREATE TABLE schema_version (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
Records the highest applied migration version from `MigrateSchema`.

#### `message_reactions`
```sql
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ On **`main`** only; not part of the latest tagged release until you tag and publ
- **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)).
- **Dependencies**: **modernc.org/sqlite** v1.56.0 (journal-rollback corruption fix; **modernc.org/libc** v1.74.4); **github.com/lucasb-eyer/go-colorful** v1.4.1.

- **Server**: **Fix:** active permanent bans and unexpired temp kicks load from `ban_history` on hub start (`expires_at` NULL = permanent; non-NULL = kick expiry), so moderation survives process restart. Pre-upgrade open rows without `expires_at` load as permanent. Permanent bans are presence-only in memory (no 100-year sentinel expiry).
- **Server**: **Fix:** schema bootstrap uses versioned `MigrateSchema` (`schema_version`) and hard-fails when required tables or `ban_history.expires_at` are missing instead of warning and continuing. SQLite/Postgres apply each version inside a transaction (mid-migration failure rolls back); MySQL DDL cannot participate in multi-statement transactions (implicit commit), so steps run without a wrapping transaction and `schema_version` is recorded only after a successful apply.
- **CI**: Pin **golangci-lint** v2.12.2 and **govulncheck** v1.6.0 (no `@latest`) in the main and nested-module jobs; add `.golangci.yml` (v2) enabling govet/ineffassign/staticcheck with `all` minus `ST*`/`QF*` (bug-focused checks, not SA*-only).
- **Server**: Connected-user lookups (`KickUser`, `kickUser`, `ForceDisconnectUser`, `broadcastDM`) use an O(1) `clientsByUsername` map under `clientsMutex`.
- **Docs**: **ARCHITECTURE** documents `MigrateSchema` / `schema_version` / `ban_history.expires_at`; **TESTING** local lint install pins match CI (no `@latest`).
- **Server**: **Fix:** `:ban` / `:kick` close any open `ban_history` row before inserting (at most one open row per user), persist to the DB before updating in-memory enforcement state, and load the latest open row by `id` on hub start so kick-then-ban (and legacy duplicates) restart cleanly.
## 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
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ go test -v -race ./...

### Local validation (PowerShell)

From the repo root, with `golangci-lint` and `govulncheck` on `PATH` (install with `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` and `go install golang.org/x/vuln/cmd/govulncheck@latest`):
From the repo root, with `golangci-lint` and `govulncheck` on `PATH` (match CI pins in `.github/workflows/go.yml`: `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2` and `go install golang.org/x/vuln/cmd/govulncheck@v1.6.0`):

```powershell
$env:GOTOOLCHAIN = "auto"
Expand Down
9 changes: 7 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ func main() {
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
server.CreateSchema(db)
if err := server.MigrateSchema(db); err != nil {
log.Fatalf("Failed to migrate database schema: %v", err)
}

// Set up plugin directories
pluginDir := cfg.ConfigDir + "/plugins"
Expand All @@ -305,7 +307,10 @@ func main() {
})
}

hub := server.NewHub(pluginDir, dataDir, registryURL, db)
hub, err := server.NewHub(pluginDir, dataDir, registryURL, db)
if err != nil {
log.Fatalf("Failed to create hub: %v", err)
}
go hub.Run()

// Log server startup
Expand Down
2 changes: 1 addition & 1 deletion server/admin_panel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func setupPanelEnv(t *testing.T) (*AdminPanel, func()) {
CreateSchema(db)
pluginDir := filepath.Join(tdir, "plugins")
dataDir := filepath.Join(tdir, "data")
hub := NewHub(pluginDir, dataDir, "", db)
hub := mustNewHub(t, pluginDir, dataDir, "", db)
cfg := &appcfg.Config{Port: 8080, AdminKey: "k", Admins: []string{"a"}, DBPath: dbPath, ConfigDir: tdir}
panel := NewAdminPanel(hub, db, hub.GetPluginManager(), cfg)
return panel, func() { _ = db.Close() }
Expand Down
2 changes: 1 addition & 1 deletion server/admin_web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func setupTestServerEnv(t *testing.T) (*sql.DB, *Hub, *appcfg.Config, func()) {
_ = os.MkdirAll(pluginDir, 0o755)
_ = os.MkdirAll(dataDir, 0o755)

hub := NewHub(pluginDir, dataDir, "", db)
hub := mustNewHub(t, pluginDir, dataDir, "", db)
go func() { // run hub in background
hub.Run()
}()
Expand Down
Loading