23 Commits

Author SHA1 Message Date
clawbot
3b42620749 feat: split Dockerfile into dedicated lint stage
All checks were successful
check / check (push) Successful in 6s
Use pre-built golangci/golangci-lint:v2.1.6 image for fast lint feedback
instead of installing golangci-lint from source on every build.

- Lint stage: runs fmt-check and lint using pre-built image
- Build stage: runs tests and compiles binaries
- COPY --from=lint forces BuildKit to execute the lint stage
- All images pinned by sha256 digest
- Runtime stage unchanged
2026-03-02 00:03:04 -08:00
cd909d59c4 Merge pull request 'feat: logout, users/me, user count, session timeout' (#24) from feature/mvp-remaining into main
All checks were successful
check / check (push) Successful in 1m58s
Reviewed-on: #24
2026-03-01 15:47:03 +01:00
clawbot
f5cc098b7b docs: update README for new endpoints, fix config name, remove dead field
All checks were successful
check / check (push) Successful in 1m24s
- Document POST /api/v1/logout endpoint
- Document GET /api/v1/users/me endpoint
- Add 'users' field to GET /api/v1/server response docs
- Fix config: SESSION_TIMEOUT -> SESSION_IDLE_TIMEOUT
- Update storage section: session expiry is implemented
- Update roadmap: move session expiry to implemented
- Remove dead SessionTimeout config field from Go code
2026-03-01 06:41:10 -08:00
user
4d7b7618b2 fix: send QUIT notifications for background idle cleanup
All checks were successful
check / check (push) Successful in 2m2s
The background idle cleanup (DeleteStaleUsers) was removing stale
clients/sessions directly via SQL without sending QUIT notifications
to channel members. This caused timed-out users to silently disappear
from channels.

Now runCleanup identifies sessions that will be orphaned by the stale
client deletion and calls cleanupUser for each one first, ensuring
QUIT messages are sent to all channel members — matching the explicit
logout behavior.

Also refactored cleanupUser to accept context.Context instead of
*http.Request so it can be called from both HTTP handlers and the
background cleanup goroutine.
2026-03-01 06:33:15 -08:00
user
910a5c2606 fix: OnStart ctx bug, rename session→user, full logout cleanup
All checks were successful
check / check (push) Successful in 1m57s
- Use context.Background() for cleanup goroutine instead of
  OnStart ctx which is cancelled after startup completes
- Rename GetSessionCount→GetUserCount, DeleteStaleSessions→
  DeleteStaleUsers to reflect that sessions represent users
- HandleLogout now fully cleans up when last client disconnects:
  parts all channels (notifying members via QUIT), removes
  empty channels, and deletes the session/user record
- docker build passes, all tests green, 0 lint issues
2026-02-28 11:14:23 -08:00
bdc243224b feat: add session idle timeout cleanup goroutine
All checks were successful
check / check (push) Successful in 1m58s
- Periodic cleanup loop deletes stale clients based on SESSION_IDLE_TIMEOUT
- Orphaned sessions (no clients) are cleaned up automatically
- last_seen already updated on each authenticated request via GetSessionByToken
2026-02-28 10:59:09 -08:00
5981c750a4 feat: add SESSION_IDLE_TIMEOUT config
- New env var SESSION_IDLE_TIMEOUT (default 24h)
- Parsed as time.Duration in handlers
2026-02-28 10:59:09 -08:00
6cfab21eaa feat: add logout endpoint and users/me endpoint
- POST /api/v1/logout: deletes client token, returns {status: ok}
- GET /api/v1/users/me: returns session info (delegates to HandleState)
- Add DeleteClient, GetSessionCount, ClientCountForSession, DeleteStaleSessions to db layer
- Add user count to GET /api/v1/server response
- Extract setupAPIv1 to fix funlen lint issue
2026-02-28 10:59:09 -08:00
4a0ed57fc0 Merge pull request 'feat: password-based registration and login (closes #1)' (#23) from feature/auth-passwords into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #23
Reviewed-by: Jeffrey Paul <sneak@noreply.example.org>
2026-02-28 19:57:40 +01:00
user
52c85724a7 fix: remove unused //nolint:gosec directives on password fields
All checks were successful
check / check (push) Successful in 2m4s
2026-02-28 10:33:59 -08:00
clawbot
69c9550bb2 consolidate password_hash into 001 migration
Some checks failed
check / check (push) Failing after 1m27s
Pre-1.0, no installed base — merge 002_add_passwords.sql into
001_initial.sql and remove the separate migration file.
2026-02-28 07:59:01 -08:00
7047167dc8 Add tests for register and login endpoints
Some checks failed
check / check (push) Failing after 1m39s
2026-02-27 05:00:51 -08:00
3cd942ffa5 Add /api/v1/register and /api/v1/login routes 2026-02-27 04:55:40 -08:00
b8794c2587 Add register and login HTTP handlers 2026-02-27 04:55:31 -08:00
70aa15e758 Add RegisterUser and LoginUser DB functions with bcrypt 2026-02-27 04:55:06 -08:00
5e26e53187 Add migration 002: add password_hash column to sessions 2026-02-27 04:54:31 -08:00
02b906badb Merge pull request 'feat: MVP two-user chat via embedded SPA (closes #9)' (#22) from feat/mvp-two-user-chat into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #22
2026-02-27 13:51:20 +01:00
clawbot
32419fb1f7 feat: MVP two-user chat via embedded SPA (#9)
All checks were successful
check / check (push) Successful in 1m51s
Backend:
- Session/client UUID model: sessions table (uuid, nick, signing_key),
  clients table (uuid, session_id, token) with per-client message queues
- MOTD delivery as IRC numeric messages (375/372/376) on connect
- EnqueueToSession fans out to all clients of a session
- EnqueueToClient for targeted delivery (MOTD)
- All queries updated for session/client model

SPA client:
- Long-poll loop (15s timeout) instead of setInterval
- IRC message envelope parsing (command/from/to/body)
- Display JOIN/PART/NICK/TOPIC/QUIT system messages
- Nick change via /nick command
- Topic display in header bar
- Unread count badges on inactive tabs
- Auto-rejoin channels on reconnect (localStorage)
- Connection status indicator
- Message deduplication by UUID
- Channel history loaded on join
- /topic command support

Closes #9
2026-02-27 02:21:48 -08:00
2d08a8476f Merge pull request 'dockerfile: use CGO_ENABLED=0 for binary builds (closes #13)' (#21) from fix/cgo-disabled into main
All checks were successful
check / check (push) Successful in 4s
Reviewed-on: #21
2026-02-27 08:47:10 +01:00
f0c4a5bb47 dockerfile: use CGO_ENABLED=0 for binary builds
All checks were successful
check / check (push) Successful in 5s
modernc.org/sqlite is pure Go — no cgo needed at runtime.
build-base remains for make check (-race requires cgo).
Fixes #13.
2026-02-26 22:28:23 -08:00
cbc93473fc Merge pull request 'MVP 1.0: IRC-over-HTTP chat server' (#10) from feature/mvp-1.0 into main
All checks were successful
check / check (push) Successful in 5s
Reviewed-on: #10
2026-02-27 07:21:34 +01:00
clawbot
a57a73e94e fix: address all PR #10 review findings
All checks were successful
check / check (push) Successful in 2m19s
Security:
- Add channel membership check before PRIVMSG (prevents non-members from sending)
- Add membership check on history endpoint (channels require membership, DMs scoped to own nick)
- Enforce MaxBytesReader on all POST request bodies
- Fix rand.Read error being silently ignored in token generation

Data integrity:
- Fix TOCTOU race in GetOrCreateChannel using INSERT OR IGNORE + SELECT

Build:
- Add CGO_ENABLED=0 to golangci-lint install in Dockerfile (fixes alpine build)

Linting:
- Strict .golangci.yml: only wsl disabled (deprecated in v2)
- Re-enable exhaustruct, depguard, godot, wrapcheck, varnamelen
- Fix linters-settings -> linters.settings for v2 config format
- Fix ALL lint findings in actual code (no linter config weakening)
- Wrap all external package errors (wrapcheck)
- Fill struct fields or add targeted nolint:exhaustruct where appropriate
- Rename short variables (ts->timestamp, n->bufIndex, etc.)
- Add depguard deny policy for io/ioutil and math/rand
- Exclude G704 (SSRF) in gosec config (CLI client takes user-configured URLs)

Tests:
- Add security tests (TestNonMemberCannotSend, TestHistoryNonMember)
- Split TestInsertAndPollMessages for reduced complexity
- Fix parallel test safety (viper global state prevents parallelism)
- Use t.Context() instead of context.Background() in tests

Docker build verified passing locally.
2026-02-26 21:21:49 -08:00
user
4b4a337a88 fix: revert .golangci.yml to main, fix all lint issues in code
Some checks failed
check / check (push) Failing after 1m5s
- Restore original .golangci.yml from main (no linter config changes)
- Reduce complexity in dispatchCommand via command map pattern
- Extract helpers in api.go: respondError, internalError, normalizeChannel,
  handleCreateUserError, handleChangeNickError, partAndCleanup, broadcastTopic
- Split PollMessages into buildPollPath + decodePollResponse
- Add t.Parallel() to all tests, make subtests independent
- Extract test fx providers into named functions to reduce funlen
- Use mutex to serialize viper access in parallel tests
- Extract PRIVMSG constant, add nolint for gosec false positives
- Split long test functions into focused test cases
- Add blank lines before expressions per wsl_v5
2026-02-26 20:45:47 -08:00
33 changed files with 4800 additions and 2549 deletions

View File

@@ -7,22 +7,7 @@ run:
linters:
default: all
disable:
- exhaustruct
- depguard
- godot
- wsl
- wrapcheck
- varnamelen
- dupl
- paralleltest
- nlreturn
- tagliatelle
- goconst
- funlen
- maintidx
- cyclop
- gocognit
- lll
- wsl # Deprecated in v2, replaced by wsl_v5
settings:
lll:
line-length: 88
@@ -33,7 +18,19 @@ linters:
max-complexity: 15
dupl:
threshold: 100
gosec:
excludes:
- G704
depguard:
rules:
all:
deny:
- pkg: "io/ioutil"
desc: "Deprecated; use io and os packages."
- pkg: "math/rand$"
desc: "Use crypto/rand for security-sensitive code."
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,23 +1,33 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage — tests and compilation
# golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
WORKDIR /src
RUN apk add --no-cache git build-base make
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run all checks — build fails if branch is not green
RUN make check
RUN make test
# Build binaries
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
ARG VERSION=dev
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
# alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709

View File

@@ -1158,6 +1158,55 @@ curl -s http://localhost:8080/api/v1/channels/general/members \
-H "Authorization: Bearer $TOKEN" | jq .
```
### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
**Request:** No body. Requires auth.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token |
**curl example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/users/me — Current User Info
Return the current user's session state. This is an alias for
`GET /api/v1/state`.
**Request:** No body. Requires auth.
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"}
]
}
```
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/server — Server Info
Return server metadata. No authentication required.
@@ -1166,10 +1215,17 @@ Return server metadata. No authentication required.
```json
{
"name": "My Chat Server",
"motd": "Welcome! Be nice."
"motd": "Welcome! Be nice.",
"users": 42
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `name` | string | Server display name |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required.
@@ -1572,8 +1628,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
planned.
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT`. Session expiry by `SESSION_TIMEOUT`
is planned.
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
24h) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
---
@@ -1590,7 +1648,7 @@ directory is also loaded automatically via
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
| `SESSION_TIMEOUT` | int | `86400` | Session idle timeout in seconds (planned). Sessions with no activity for this long are expired and the nick is released. |
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) |
@@ -1610,7 +1668,7 @@ SERVER_NAME=My Chat Server
MOTD=Welcome! Be excellent to each other.
DEBUG=false
DBURL=file:./data.db?_journal_mode=WAL
SESSION_TIMEOUT=86400
SESSION_IDLE_TIMEOUT=24h
```
---
@@ -2008,11 +2066,14 @@ GET /api/v1/challenge
- [x] Docker deployment
- [x] Prometheus metrics endpoint
- [x] Health check endpoint
- [x] Session expiry — auto-expire idle sessions, release nicks
- [x] Logout endpoint (`POST /api/v1/logout`)
- [x] Current user endpoint (`GET /api/v1/users/me`)
- [x] User count in server info (`GET /api/v1/server`)
### Post-MVP (Planned)
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
- [ ] **Session expiry** — auto-expire idle sessions, release nicks
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`

View File

@@ -1,3 +1,4 @@
// Package chatapi provides a client for the chat server API.
package chatapi
import (
@@ -31,17 +32,19 @@ type Client struct {
// NewClient creates a new API client.
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: httpTimeout},
return &Client{ //nolint:exhaustruct // Token set after CreateSession
BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout,
},
}
}
// CreateSession creates a new session on the server.
func (c *Client) CreateSession(
func (client *Client) CreateSession(
nick string,
) (*SessionResponse, error) {
data, err := c.do(
data, err := client.do(
http.MethodPost,
"/api/v1/session",
&SessionRequest{Nick: nick},
@@ -57,14 +60,14 @@ func (c *Client) CreateSession(
return nil, fmt.Errorf("decode session: %w", err)
}
c.Token = resp.Token
client.Token = resp.Token
return &resp, nil
}
// GetState returns the current user state.
func (c *Client) GetState() (*StateResponse, error) {
data, err := c.do(
func (client *Client) GetState() (*StateResponse, error) {
data, err := client.do(
http.MethodGet, "/api/v1/state", nil,
)
if err != nil {
@@ -82,8 +85,8 @@ func (c *Client) GetState() (*StateResponse, error) {
}
// SendMessage sends a message (any IRC command).
func (c *Client) SendMessage(msg *Message) error {
_, err := c.do(
func (client *Client) SendMessage(msg *Message) error {
_, err := client.do(
http.MethodPost, "/api/v1/messages", msg,
)
@@ -91,11 +94,11 @@ func (c *Client) SendMessage(msg *Message) error {
}
// PollMessages long-polls for new messages.
func (c *Client) PollMessages(
func (client *Client) PollMessages(
afterID int64,
timeout int,
) (*PollResult, error) {
client := &http.Client{
pollClient := &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
@@ -113,28 +116,30 @@ func (c *Client) PollMessages(
path := "/api/v1/messages?" + params.Encode()
req, err := http.NewRequestWithContext(
request, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
c.BaseURL+path,
client.BaseURL+path,
nil,
)
if err != nil {
return nil, err
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.Token)
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
resp, err := client.Do(req)
resp, err := pollClient.Do(request)
if err != nil {
return nil, err
return nil, fmt.Errorf("poll request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, fmt.Errorf("read poll body: %w", err)
}
if resp.StatusCode >= httpErrThreshold {
@@ -160,22 +165,28 @@ func (c *Client) PollMessages(
}
// JoinChannel joins a channel.
func (c *Client) JoinChannel(channel string) error {
return c.SendMessage(
&Message{Command: "JOIN", To: channel},
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: "JOIN", To: channel,
},
)
}
// PartChannel leaves a channel.
func (c *Client) PartChannel(channel string) error {
return c.SendMessage(
&Message{Command: "PART", To: channel},
func (client *Client) PartChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: "PART", To: channel,
},
)
}
// ListChannels returns all channels on the server.
func (c *Client) ListChannels() ([]Channel, error) {
data, err := c.do(
func (client *Client) ListChannels() (
[]Channel, error,
) {
data, err := client.do(
http.MethodGet, "/api/v1/channels", nil,
)
if err != nil {
@@ -186,19 +197,21 @@ func (c *Client) ListChannels() ([]Channel, error) {
err = json.Unmarshal(data, &channels)
if err != nil {
return nil, err
return nil, fmt.Errorf(
"decode channels: %w", err,
)
}
return channels, nil
}
// GetMembers returns members of a channel.
func (c *Client) GetMembers(
func (client *Client) GetMembers(
channel string,
) ([]string, error) {
name := strings.TrimPrefix(channel, "#")
data, err := c.do(
data, err := client.do(
http.MethodGet,
"/api/v1/channels/"+url.PathEscape(name)+
"/members",
@@ -221,8 +234,10 @@ func (c *Client) GetMembers(
}
// GetServerInfo returns server info.
func (c *Client) GetServerInfo() (*ServerInfo, error) {
data, err := c.do(
func (client *Client) GetServerInfo() (
*ServerInfo, error,
) {
data, err := client.do(
http.MethodGet, "/api/v1/server", nil,
)
if err != nil {
@@ -233,13 +248,15 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) {
err = json.Unmarshal(data, &info)
if err != nil {
return nil, err
return nil, fmt.Errorf(
"decode server info: %w", err,
)
}
return &info, nil
}
func (c *Client) do(
func (client *Client) do(
method, path string,
body any,
) ([]byte, error) {
@@ -254,25 +271,27 @@ func (c *Client) do(
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(
request, err := http.NewRequestWithContext(
context.Background(),
method,
c.BaseURL+path,
client.BaseURL+path,
bodyReader,
)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
request.Header.Set(
"Content-Type", "application/json",
)
if c.Token != "" {
req.Header.Set(
"Authorization", "Bearer "+c.Token,
if client.Token != "" {
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
}
resp, err := c.HTTPClient.Do(req)
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}

View File

@@ -1,4 +1,3 @@
// Package chatapi provides API types and client for chat-cli.
package chatapi
import "time"
@@ -36,19 +35,19 @@ type Message struct {
// BodyLines returns the body as a string slice.
func (m *Message) BodyLines() []string {
switch v := m.Body.(type) {
switch bodyVal := m.Body.(type) {
case []any:
lines := make([]string, 0, len(v))
lines := make([]string, 0, len(bodyVal))
for _, item := range v {
if s, ok := item.(string); ok {
lines = append(lines, s)
for _, item := range bodyVal {
if str, ok := item.(string); ok {
lines = append(lines, str)
}
}
return lines
case []string:
return v
return bodyVal
default:
return nil
}

View File

@@ -32,7 +32,7 @@ type App struct {
}
func main() {
app := &App{
app := &App{ //nolint:exhaustruct
ui: NewUI(),
nick: "guest",
}
@@ -85,7 +85,7 @@ func (a *App) handleInput(text string) {
return
}
err := a.client.SendMessage(&api.Message{
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: "PRIVMSG",
To: target,
Body: []string{text},
@@ -98,7 +98,7 @@ func (a *App) handleInput(text string) {
return
}
ts := time.Now().Format(timeFormat)
timestamp := time.Now().Format(timeFormat)
a.mu.Lock()
nick := a.nick
@@ -106,7 +106,7 @@ func (a *App) handleInput(text string) {
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
ts, nick, text,
timestamp, nick, text,
))
}
@@ -227,7 +227,7 @@ func (a *App) cmdNick(nick string) {
return
}
err := a.client.SendMessage(&api.Message{
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: "NICK",
Body: []string{nick},
})
@@ -362,7 +362,7 @@ func (a *App) cmdMsg(args string) {
return
}
err := a.client.SendMessage(&api.Message{
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: "PRIVMSG",
To: target,
Body: []string{text},
@@ -375,11 +375,11 @@ func (a *App) cmdMsg(args string) {
return
}
ts := time.Now().Format(timeFormat)
timestamp := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
ts, nick, text,
timestamp, nick, text,
))
}
@@ -420,7 +420,7 @@ func (a *App) cmdTopic(args string) {
}
if args == "" {
err := a.client.SendMessage(&api.Message{
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: "TOPIC",
To: target,
})
@@ -433,7 +433,7 @@ func (a *App) cmdTopic(args string) {
return
}
err := a.client.SendMessage(&api.Message{
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: "TOPIC",
To: target,
Body: []string{args},
@@ -519,18 +519,18 @@ func (a *App) cmdWindow(args string) {
return
}
var n int
var bufIndex int
_, _ = fmt.Sscanf(args, "%d", &n)
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.ui.SwitchBuffer(n)
a.ui.SwitchBuffer(bufIndex)
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
if n >= 0 && n < a.ui.BufferCount() {
buf := a.ui.buffers[n]
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
buf := a.ui.buffers[bufIndex]
if buf.Name != "(status)" {
a.mu.Lock()
a.target = buf.Name
@@ -550,7 +550,7 @@ func (a *App) cmdQuit() {
if a.connected && a.client != nil {
_ = a.client.SendMessage(
&api.Message{Command: "QUIT"},
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
)
}
@@ -625,7 +625,7 @@ func (a *App) pollLoop() {
}
func (a *App) handleServerMessage(msg *api.Message) {
ts := a.formatTS(msg)
timestamp := a.formatTS(msg)
a.mu.Lock()
myNick := a.nick
@@ -633,21 +633,21 @@ func (a *App) handleServerMessage(msg *api.Message) {
switch msg.Command {
case "PRIVMSG":
a.handlePrivmsgEvent(msg, ts, myNick)
a.handlePrivmsgEvent(msg, timestamp, myNick)
case "JOIN":
a.handleJoinEvent(msg, ts)
a.handleJoinEvent(msg, timestamp)
case "PART":
a.handlePartEvent(msg, ts)
a.handlePartEvent(msg, timestamp)
case "QUIT":
a.handleQuitEvent(msg, ts)
a.handleQuitEvent(msg, timestamp)
case "NICK":
a.handleNickEvent(msg, ts, myNick)
a.handleNickEvent(msg, timestamp, myNick)
case "NOTICE":
a.handleNoticeEvent(msg, ts)
a.handleNoticeEvent(msg, timestamp)
case "TOPIC":
a.handleTopicEvent(msg, ts)
a.handleTopicEvent(msg, timestamp)
default:
a.handleDefaultEvent(msg, ts)
a.handleDefaultEvent(msg, timestamp)
}
}
@@ -660,7 +660,7 @@ func (a *App) formatTS(msg *api.Message) string {
}
func (a *App) handlePrivmsgEvent(
msg *api.Message, ts, myNick string,
msg *api.Message, timestamp, myNick string,
) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
@@ -676,12 +676,12 @@ func (a *App) handlePrivmsgEvent(
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
ts, msg.From, text,
timestamp, msg.From, text,
))
}
func (a *App) handleJoinEvent(
msg *api.Message, ts string,
msg *api.Message, timestamp string,
) {
if msg.To == "" {
return
@@ -689,12 +689,12 @@ func (a *App) handleJoinEvent(
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s",
ts, msg.From, msg.To,
timestamp, msg.From, msg.To,
))
}
func (a *App) handlePartEvent(
msg *api.Message, ts string,
msg *api.Message, timestamp string,
) {
if msg.To == "" {
return
@@ -706,18 +706,18 @@ func (a *App) handlePartEvent(
if reason != "" {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)",
ts, msg.From, msg.To, reason,
timestamp, msg.From, msg.To, reason,
))
} else {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
ts, msg.From, msg.To,
timestamp, msg.From, msg.To,
))
}
}
func (a *App) handleQuitEvent(
msg *api.Message, ts string,
msg *api.Message, timestamp string,
) {
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
@@ -725,18 +725,18 @@ func (a *App) handleQuitEvent(
if reason != "" {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)",
ts, msg.From, reason,
timestamp, msg.From, reason,
))
} else {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit",
ts, msg.From,
timestamp, msg.From,
))
}
}
func (a *App) handleNickEvent(
msg *api.Message, ts, myNick string,
msg *api.Message, timestamp, myNick string,
) {
lines := msg.BodyLines()
@@ -757,24 +757,24 @@ func (a *App) handleNickEvent(
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s",
ts, msg.From, newNick,
timestamp, msg.From, newNick,
))
}
func (a *App) handleNoticeEvent(
msg *api.Message, ts string,
msg *api.Message, timestamp string,
) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s",
ts, msg.From, text,
timestamp, msg.From, text,
))
}
func (a *App) handleTopicEvent(
msg *api.Message, ts string,
msg *api.Message, timestamp string,
) {
if msg.To == "" {
return
@@ -785,12 +785,12 @@ func (a *App) handleTopicEvent(
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s",
ts, msg.From, text,
timestamp, msg.From, text,
))
}
func (a *App) handleDefaultEvent(
msg *api.Message, ts string,
msg *api.Message, timestamp string,
) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
@@ -798,7 +798,7 @@ func (a *App) handleDefaultEvent(
if text != "" {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white][%s] %s",
ts, msg.Command, text,
timestamp, msg.Command, text,
))
}
}

View File

@@ -32,10 +32,10 @@ type UI struct {
// NewUI creates the tview-based IRC-like UI.
func NewUI() *UI {
ui := &UI{
ui := &UI{ //nolint:exhaustruct,varnamelen // fields set below; ui is idiomatic
app: tview.NewApplication(),
buffers: []*Buffer{
{Name: "(status)", Lines: nil},
{Name: "(status)", Lines: nil, Unread: 0},
},
}
@@ -58,7 +58,12 @@ func NewUI() *UI {
// Run starts the UI event loop (blocks).
func (ui *UI) Run() error {
return ui.app.Run()
err := ui.app.Run()
if err != nil {
return fmt.Errorf("run ui: %w", err)
}
return nil
}
// Stop stops the UI.
@@ -80,6 +85,7 @@ func (ui *UI) AddLine(bufferName, line string) {
cur := ui.buffers[ui.currentBuffer]
if cur != buf {
buf.Unread++
ui.refreshStatusBar()
}
@@ -99,15 +105,15 @@ func (ui *UI) AddStatus(line string) {
}
// SwitchBuffer switches to the buffer at index n.
func (ui *UI) SwitchBuffer(n int) {
func (ui *UI) SwitchBuffer(bufIndex int) {
ui.app.QueueUpdateDraw(func() {
if n < 0 || n >= len(ui.buffers) {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
ui.currentBuffer = n
ui.currentBuffer = bufIndex
buf := ui.buffers[n]
buf := ui.buffers[bufIndex]
buf.Unread = 0
ui.messages.Clear()
@@ -281,7 +287,7 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer {
}
}
buf := &Buffer{Name: name}
buf := &Buffer{Name: name, Lines: nil, Unread: 0}
ui.buffers = append(ui.buffers, buf)
return buf

13
go.mod
View File

@@ -4,14 +4,18 @@ go 1.24.0
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/rivo/tview v0.42.0
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.45.0
)
@@ -21,9 +25,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -33,7 +35,6 @@ require (
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/tview v0.42.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -47,9 +48,9 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect

30
go.sum
View File

@@ -113,12 +113,14 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -126,8 +128,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -135,30 +137,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=

View File

@@ -8,25 +8,28 @@ import (
// Broker notifies waiting clients when new messages are available.
type Broker struct {
mu sync.Mutex
listeners map[int64][]chan struct{} // userID -> list of waiting channels
listeners map[int64][]chan struct{}
}
// New creates a new Broker.
func New() *Broker {
return &Broker{
return &Broker{ //nolint:exhaustruct // mu has zero-value default
listeners: make(map[int64][]chan struct{}),
}
}
// Wait returns a channel that will be closed when a message is available for the user.
// Wait returns a channel that will be closed when a message
// is available for the user.
func (b *Broker) Wait(userID int64) chan struct{} {
ch := make(chan struct{}, 1)
waitCh := make(chan struct{}, 1)
b.mu.Lock()
b.listeners[userID] = append(b.listeners[userID], ch)
b.listeners[userID] = append(
b.listeners[userID], waitCh,
)
b.mu.Unlock()
return ch
return waitCh
}
// Notify wakes up all waiting clients for a user.
@@ -36,24 +39,29 @@ func (b *Broker) Notify(userID int64) {
delete(b.listeners, userID)
b.mu.Unlock()
for _, ch := range waiters {
for _, waiter := range waiters {
select {
case ch <- struct{}{}:
case waiter <- struct{}{}:
default:
}
}
}
// Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove(userID int64, ch chan struct{}) {
func (b *Broker) Remove(
userID int64,
waitCh chan struct{},
) {
b.mu.Lock()
defer b.mu.Unlock()
waiters := b.listeners[userID]
for i, w := range waiters {
if w == ch {
b.listeners[userID] = append(waiters[:i], waiters[i+1:]...)
for i, waiter := range waiters {
if waiter == waitCh {
b.listeners[userID] = append(
waiters[:i], waiters[i+1:]...,
)
break
}

View File

@@ -11,8 +11,8 @@ import (
func TestNewBroker(t *testing.T) {
t.Parallel()
b := broker.New()
if b == nil {
brk := broker.New()
if brk == nil {
t.Fatal("expected non-nil broker")
}
}
@@ -20,16 +20,16 @@ func TestNewBroker(t *testing.T) {
func TestWaitAndNotify(t *testing.T) {
t.Parallel()
b := broker.New()
ch := b.Wait(1)
brk := broker.New()
waitCh := brk.Wait(1)
go func() {
time.Sleep(10 * time.Millisecond)
b.Notify(1)
brk.Notify(1)
}()
select {
case <-ch:
case <-waitCh:
case <-time.After(2 * time.Second):
t.Fatal("timeout")
}
@@ -38,21 +38,22 @@ func TestWaitAndNotify(t *testing.T) {
func TestNotifyWithoutWaiters(t *testing.T) {
t.Parallel()
b := broker.New()
b.Notify(42) // should not panic
brk := broker.New()
brk.Notify(42) // should not panic.
}
func TestRemove(t *testing.T) {
t.Parallel()
b := broker.New()
ch := b.Wait(1)
b.Remove(1, ch)
brk := broker.New()
waitCh := brk.Wait(1)
b.Notify(1)
brk.Remove(1, waitCh)
brk.Notify(1)
select {
case <-ch:
case <-waitCh:
t.Fatal("should not receive after remove")
case <-time.After(50 * time.Millisecond):
}
@@ -61,20 +62,20 @@ func TestRemove(t *testing.T) {
func TestMultipleWaiters(t *testing.T) {
t.Parallel()
b := broker.New()
ch1 := b.Wait(1)
ch2 := b.Wait(1)
brk := broker.New()
waitCh1 := brk.Wait(1)
waitCh2 := brk.Wait(1)
b.Notify(1)
brk.Notify(1)
select {
case <-ch1:
case <-waitCh1:
case <-time.After(time.Second):
t.Fatal("ch1 timeout")
}
select {
case <-ch2:
case <-waitCh2:
case <-time.After(time.Second):
t.Fatal("ch2 timeout")
}
@@ -83,36 +84,38 @@ func TestMultipleWaiters(t *testing.T) {
func TestConcurrentWaitNotify(t *testing.T) {
t.Parallel()
b := broker.New()
brk := broker.New()
var wg sync.WaitGroup
var waitGroup sync.WaitGroup
const concurrency = 100
for i := range concurrency {
wg.Add(1)
for idx := range concurrency {
waitGroup.Add(1)
go func(uid int64) {
defer wg.Done()
defer waitGroup.Done()
ch := b.Wait(uid)
b.Notify(uid)
waitCh := brk.Wait(uid)
brk.Notify(uid)
select {
case <-ch:
case <-waitCh:
case <-time.After(time.Second):
t.Error("timeout")
}
}(int64(i % 10))
}(int64(idx % 10))
}
wg.Wait()
waitGroup.Wait()
}
func TestRemoveNonexistent(t *testing.T) {
t.Parallel()
b := broker.New()
ch := make(chan struct{}, 1)
b.Remove(999, ch) // should not panic
brk := broker.New()
waitCh := make(chan struct{}, 1)
brk.Remove(999, waitCh) // should not panic.
}

View File

@@ -23,25 +23,27 @@ type Params struct {
// Config holds all application configuration values.
type Config struct {
DBURL string
Debug bool
MaintenanceMode bool
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
MaxHistory int
SessionTimeout int
MaxMessageSize int
MOTD string
ServerName string
FederationKey string
params *Params
log *slog.Logger
DBURL string
Debug bool
MaintenanceMode bool
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
MaxHistory int
MaxMessageSize int
MOTD string
ServerName string
FederationKey string
SessionIdleTimeout string
params *Params
log *slog.Logger
}
// New creates a new Config by reading from files and environment variables.
func New(_ fx.Lifecycle, params Params) (*Config, error) {
func New(
_ fx.Lifecycle, params Params,
) (*Config, error) {
log := params.Logger.Get()
name := params.Globals.Appname
@@ -59,11 +61,11 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("SESSION_TIMEOUT", "86400")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("MOTD", "")
viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
err := viper.ReadInConfig()
if err != nil {
@@ -74,28 +76,28 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
}
}
s := &Config{
DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"),
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
log: log,
params: &params,
cfg := &Config{
DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
log: log,
params: &params,
}
if s.Debug {
if cfg.Debug {
params.Logger.EnableDebugLogging()
s.log = params.Logger.Get()
cfg.log = params.Logger.Get()
}
return s, nil
return cfg, nil
}

161
internal/db/auth.go Normal file
View File

@@ -0,0 +1,161 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = bcrypt.DefaultCost
var errNoPassword = errors.New(
"account has no password set",
)
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, 0, "", err
}
now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
}
// LoginUser verifies a nick/password and creates a new
// client token.
func (database *Database) LoginUser(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
var (
sessionID int64
passwordHash string
)
err := database.conn.QueryRowContext(
ctx,
`SELECT id, password_hash
FROM sessions WHERE nick = ?`,
nick,
).Scan(&sessionID, &passwordHash)
if err != nil {
return 0, 0, "", fmt.Errorf(
"get session for login: %w", err,
)
}
if passwordHash == "" {
return 0, 0, "", fmt.Errorf(
"login: %w", errNoPassword,
)
}
err = bcrypt.CompareHashAndPassword(
[]byte(passwordHash), []byte(password),
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"verify password: %w", err,
)
}
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, 0, "", err
}
now := time.Now()
res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
return 0, 0, "", fmt.Errorf(
"create login client: %w", err,
)
}
clientID, _ := res.LastInsertId()
_, _ = database.conn.ExecContext(
ctx,
"UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID,
)
return sessionID, clientID, token, nil
}

178
internal/db/auth_test.go Normal file
View File

@@ -0,0 +1,178 @@
package db_test
import (
"testing"
_ "modernc.org/sqlite"
)
func TestRegisterUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
_ = dupSID
_ = dupCID
_ = dupToken
}
func TestLoginUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify the new token works.
_, _, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "loginuser" {
t.Fatalf("expected loginuser, got %s", nick)
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
// Create anonymous session (no password).
anonSID, anonCID, anonToken, err :=
database.CreateSession(ctx, "anon")
if err != nil {
t.Fatal(err)
}
_ = anonSID
_ = anonCID
_ = anonToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "anon", "anything1")
if loginErr == nil {
t.Fatal(
"expected error for login on passwordless account",
)
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNonexistent(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "ghost", "password123")
if err == nil {
t.Fatal("expected error for nonexistent user")
}
_ = loginSID
_ = loginCID
_ = loginToken
}

View File

@@ -37,93 +37,93 @@ type Params struct {
// Database manages the SQLite connection and migrations.
type Database struct {
db *sql.DB
conn *sql.DB
log *slog.Logger
params *Params
}
// New creates a new Database and registers lifecycle hooks.
func New(
lc fx.Lifecycle,
lifecycle fx.Lifecycle,
params Params,
) (*Database, error) {
s := new(Database)
s.params = &params
s.log = params.Logger.Get()
database := &Database{ //nolint:exhaustruct // conn set in OnStart
params: &params,
log: params.Logger.Get(),
}
s.log.Info("Database instantiated")
database.log.Info("Database instantiated")
lc.Append(fx.Hook{
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s.log.Info("Database OnStart Hook")
database.log.Info("Database OnStart Hook")
return s.connect(ctx)
return database.connect(ctx)
},
OnStop: func(_ context.Context) error {
s.log.Info("Database OnStop Hook")
database.log.Info("Database OnStop Hook")
if s.db != nil {
return s.db.Close()
if database.conn != nil {
closeErr := database.conn.Close()
if closeErr != nil {
return fmt.Errorf(
"close db: %w", closeErr,
)
}
}
return nil
},
})
return s, nil
return database, nil
}
// GetDB returns the underlying sql.DB connection.
func (s *Database) GetDB() *sql.DB {
return s.db
func (database *Database) GetDB() *sql.DB {
return database.conn
}
func (s *Database) connect(ctx context.Context) error {
dbURL := s.params.Config.DBURL
func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL
if dbURL == "" {
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
}
s.log.Info("connecting to database", "url", dbURL)
database.log.Info(
"connecting to database", "url", dbURL,
)
d, err := sql.Open("sqlite", dbURL)
conn, err := sql.Open("sqlite", dbURL)
if err != nil {
s.log.Error(
"failed to open database", "error", err,
)
return err
return fmt.Errorf("open database: %w", err)
}
err = d.PingContext(ctx)
err = conn.PingContext(ctx)
if err != nil {
s.log.Error(
"failed to ping database", "error", err,
)
return err
return fmt.Errorf("ping database: %w", err)
}
d.SetMaxOpenConns(1)
conn.SetMaxOpenConns(1)
s.db = d
s.log.Info("database connected")
database.conn = conn
database.log.Info("database connected")
_, err = s.db.ExecContext(
_, err = database.conn.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
_, err = s.db.ExecContext(
_, err = database.conn.ExecContext(
ctx, "PRAGMA busy_timeout = 5000",
)
if err != nil {
return fmt.Errorf("set busy timeout: %w", err)
}
return s.runMigrations(ctx)
return database.runMigrations(ctx)
}
type migration struct {
@@ -132,10 +132,10 @@ type migration struct {
sql string
}
func (s *Database) runMigrations(
func (database *Database) runMigrations(
ctx context.Context,
) error {
_, err := s.db.ExecContext(ctx,
_, err := database.conn.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
@@ -145,37 +145,37 @@ func (s *Database) runMigrations(
)
}
migrations, err := s.loadMigrations()
migrations, err := database.loadMigrations()
if err != nil {
return err
}
for _, m := range migrations {
err = s.applyMigration(ctx, m)
for _, mig := range migrations {
err = database.applyMigration(ctx, mig)
if err != nil {
return err
}
}
s.log.Info("database migrations complete")
database.log.Info("database migrations complete")
return nil
}
func (s *Database) applyMigration(
func (database *Database) applyMigration(
ctx context.Context,
m migration,
mig migration,
) error {
var exists int
err := s.db.QueryRowContext(ctx,
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations
WHERE version = ?`,
m.version,
mig.version,
).Scan(&exists)
if err != nil {
return fmt.Errorf(
"check migration %d: %w", m.version, err,
"check migration %d: %w", mig.version, err,
)
}
@@ -183,55 +183,63 @@ func (s *Database) applyMigration(
return nil
}
s.log.Info(
database.log.Info(
"applying migration",
"version", m.version,
"name", m.name,
"version", mig.version,
"name", mig.name,
)
return s.execMigration(ctx, m)
return database.execMigration(ctx, mig)
}
func (s *Database) execMigration(
func (database *Database) execMigration(
ctx context.Context,
m migration,
mig migration,
) error {
tx, err := s.db.BeginTx(ctx, nil)
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf(
"begin tx for migration %d: %w",
m.version, err,
mig.version, err,
)
}
_, err = tx.ExecContext(ctx, m.sql)
_, err = transaction.ExecContext(ctx, mig.sql)
if err != nil {
_ = tx.Rollback()
_ = transaction.Rollback()
return fmt.Errorf(
"apply migration %d (%s): %w",
m.version, m.name, err,
mig.version, mig.name, err,
)
}
_, err = tx.ExecContext(ctx,
_, err = transaction.ExecContext(ctx,
`INSERT INTO schema_migrations (version)
VALUES (?)`,
m.version,
mig.version,
)
if err != nil {
_ = tx.Rollback()
_ = transaction.Rollback()
return fmt.Errorf(
"record migration %d: %w",
m.version, err,
mig.version, err,
)
}
return tx.Commit()
err = transaction.Commit()
if err != nil {
return fmt.Errorf(
"commit migration %d: %w",
mig.version, err,
)
}
return nil
}
func (s *Database) loadMigrations() (
func (database *Database) loadMigrations() (
[]migration,
error,
) {

View File

@@ -13,35 +13,48 @@ var testDBCounter atomic.Int64
// NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
n := testDBCounter.Add(1)
counter := testDBCounter.Add(1)
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
n,
counter,
)
d, err := sql.Open("sqlite", dsn)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
return nil, fmt.Errorf("open test db: %w", err)
}
database := &Database{db: d, log: slog.Default()}
database := &Database{ //nolint:exhaustruct // test helper, params not needed
conn: conn,
log: slog.Default(),
}
err = database.runMigrations(context.Background())
if err != nil {
closeErr := d.Close()
closeErr := conn.Close()
if closeErr != nil {
return nil, closeErr
return nil, fmt.Errorf(
"close after migration failure: %w",
closeErr,
)
}
return nil, err
return nil, fmt.Errorf(
"run test migrations: %w", err,
)
}
return database, nil
}
// Close closes the underlying database connection.
func (s *Database) Close() error {
return s.db.Close()
func (database *Database) Close() error {
err := database.conn.Close()
if err != nil {
return fmt.Errorf("close database: %w", err)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,85 +12,106 @@ import (
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
d, err := db.NewTestDatabase()
database, err := db.NewTestDatabase()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
closeErr := d.Close()
closeErr := database.Close()
if closeErr != nil {
t.Logf("close db: %v", closeErr)
}
})
return d
return database
}
func TestCreateUser(t *testing.T) {
func TestCreateSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
id, token, err := database.CreateUser(ctx, "alice")
sessionID, _, token, err := database.CreateSession(
ctx, "alice",
)
if err != nil {
t.Fatal(err)
}
if id == 0 || token == "" {
if sessionID == 0 || token == "" {
t.Fatal("expected valid id and token")
}
_, _, err = database.CreateUser(ctx, "alice")
if err == nil {
_, _, dupToken, dupErr := database.CreateSession(
ctx, "alice",
)
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
_ = dupToken
}
func TestGetUserByToken(t *testing.T) {
func TestGetSessionByToken(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, token, err := database.CreateUser(ctx, "bob")
_, _, token, err := database.CreateSession(ctx, "bob")
if err != nil {
t.Fatal(err)
}
id, nick, err := database.GetUserByToken(ctx, token)
sessionID, clientID, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "bob" || id == 0 {
if nick != "bob" || sessionID == 0 || clientID == 0 {
t.Fatalf("expected bob, got %s", nick)
}
_, _, err = database.GetUserByToken(ctx, "badtoken")
if err == nil {
badSID, badCID, badNick, badErr :=
database.GetSessionByToken(ctx, "badtoken")
if badErr == nil {
t.Fatal("expected error for bad token")
}
if badSID != 0 || badCID != 0 || badNick != "" {
t.Fatal("expected zero values on error")
}
}
func TestGetUserByNick(t *testing.T) {
func TestGetSessionByNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, _, err := database.CreateUser(ctx, "charlie")
charlieID, charlieClientID, charlieToken, err :=
database.CreateSession(ctx, "charlie")
if err != nil {
t.Fatal(err)
}
id, err := database.GetUserByNick(ctx, "charlie")
if charlieID == 0 || charlieClientID == 0 {
t.Fatal("expected valid session/client IDs")
}
if charlieToken == "" {
t.Fatal("expected non-empty token")
}
id, err := database.GetSessionByNick(ctx, "charlie")
if err != nil || id == 0 {
t.Fatal("expected to find charlie")
}
_, err = database.GetUserByNick(ctx, "nobody")
_, err = database.GetSessionByNick(ctx, "nobody")
if err == nil {
t.Fatal("expected error for unknown nick")
}
@@ -129,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, "user1")
sid, _, _, err := database.CreateSession(ctx, "user1")
if err != nil {
t.Fatal(err)
}
@@ -139,22 +160,22 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
ids, err := database.GetChannelMemberIDs(ctx, chID)
if err != nil || len(ids) != 1 || ids[0] != uid {
t.Fatal("expected user in channel")
if err != nil || len(ids) != 1 || ids[0] != sid {
t.Fatal("expected session in channel")
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
@@ -178,17 +199,17 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err)
}
uid, _, err := database.CreateUser(ctx, "temp")
sid, _, _, err := database.CreateSession(ctx, "temp")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
@@ -204,7 +225,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
}
}
func createUserWithChannels(
func createSessionWithChannels(
t *testing.T,
database *db.Database,
nick, ch1Name, ch2Name string,
@@ -213,7 +234,7 @@ func createUserWithChannels(
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, nick)
sid, _, _, err := database.CreateSession(ctx, nick)
if err != nil {
t.Fatal(err)
}
@@ -232,29 +253,29 @@ func createUserWithChannels(
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch1, uid)
err = database.JoinChannel(ctx, ch1, sid)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch2, uid)
err = database.JoinChannel(ctx, ch2, sid)
if err != nil {
t.Fatal(err)
}
return uid, ch1, ch2
return sid, ch1, ch2
}
func TestListChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
sid, _, _ := createSessionWithChannels(
t, database, "lister", "#a", "#b",
)
channels, err := database.ListChannels(
t.Context(), uid,
t.Context(), sid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
@@ -295,17 +316,21 @@ func TestChangeNick(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid, token, err := database.CreateUser(ctx, "old")
sid, _, token, err := database.CreateSession(
ctx, "old",
)
if err != nil {
t.Fatal(err)
}
err = database.ChangeNick(ctx, uid, "new")
err = database.ChangeNick(ctx, sid, "new")
if err != nil {
t.Fatal(err)
}
_, nick, err := database.GetUserByToken(ctx, token)
_, _, nick, err := database.GetSessionByToken(
ctx, token,
)
if err != nil {
t.Fatal(err)
}
@@ -349,27 +374,56 @@ func TestSetTopic(t *testing.T) {
}
}
func TestInsertAndPollMessages(t *testing.T) {
func TestInsertMessage(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, "poller")
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil,
)
if err != nil || dbID == 0 || msgUUID == "" {
t.Fatal("insert failed")
if err != nil {
t.Fatal(err)
}
err = database.EnqueueMessage(ctx, uid, dbID)
if dbID == 0 || msgUUID == "" {
t.Fatal("expected valid id and uuid")
}
}
func TestPollMessages(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, _, token, err := database.CreateSession(
ctx, "poller",
)
if err != nil {
t.Fatal(err)
}
_, clientID, _, err := database.GetSessionByToken(
ctx, token,
)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil,
)
if err != nil {
t.Fatal(err)
}
err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil {
t.Fatal(err)
}
@@ -377,7 +431,7 @@ func TestInsertAndPollMessages(t *testing.T) {
const batchSize = 10
msgs, lastQID, err := database.PollMessages(
ctx, uid, 0, batchSize,
ctx, clientID, 0, batchSize,
)
if err != nil {
t.Fatal(err)
@@ -400,7 +454,7 @@ func TestInsertAndPollMessages(t *testing.T) {
}
msgs, _, _ = database.PollMessages(
ctx, uid, lastQID, batchSize,
ctx, clientID, lastQID, batchSize,
)
if len(msgs) != 0 {
@@ -447,13 +501,15 @@ func TestGetHistory(t *testing.T) {
}
}
func TestDeleteUser(t *testing.T) {
func TestDeleteSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, "deleteme")
sid, _, _, err := database.CreateSession(
ctx, "deleteme",
)
if err != nil {
t.Fatal(err)
}
@@ -465,19 +521,19 @@ func TestDeleteUser(t *testing.T) {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteUser(ctx, uid)
err = database.DeleteSession(ctx, sid)
if err != nil {
t.Fatal(err)
}
_, err = database.GetUserByNick(ctx, "deleteme")
_, err = database.GetSessionByNick(ctx, "deleteme")
if err == nil {
t.Fatal("user should be deleted")
t.Fatal("session should be deleted")
}
ids, _ := database.GetChannelMemberIDs(ctx, chID)
@@ -492,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid1, _, err := database.CreateUser(ctx, "m1")
sid1, _, _, err := database.CreateSession(ctx, "m1")
if err != nil {
t.Fatal(err)
}
uid2, _, err := database.CreateUser(ctx, "m2")
sid2, _, _, err := database.CreateSession(ctx, "m2")
if err != nil {
t.Fatal(err)
}
@@ -509,12 +565,12 @@ func TestChannelMembers(t *testing.T) {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid1)
err = database.JoinChannel(ctx, chID, sid1)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid2)
err = database.JoinChannel(ctx, chID, sid2)
if err != nil {
t.Fatal(err)
}
@@ -528,17 +584,17 @@ func TestChannelMembers(t *testing.T) {
}
}
func TestGetAllChannelMembershipsForUser(t *testing.T) {
func TestGetSessionChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
sid, _, _ := createSessionWithChannels(
t, database, "multi", "#m1", "#m2",
)
channels, err :=
database.GetAllChannelMembershipsForUser(
t.Context(), uid,
database.GetSessionChannels(
t.Context(), sid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
@@ -547,3 +603,51 @@ func TestGetAllChannelMembershipsForUser(t *testing.T) {
)
}
}
func TestEnqueueToClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, _, token, err := database.CreateSession(
ctx, "enqclient",
)
if err != nil {
t.Fatal(err)
}
_, clientID, _, err := database.GetSessionByToken(
ctx, token,
)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", body, nil,
)
if err != nil {
t.Fatal(err)
}
err = database.EnqueueToClient(ctx, clientID, dbID)
if err != nil {
t.Fatal(err)
}
const batchSize = 10
msgs, _, err := database.PollMessages(
ctx, clientID, 0, batchSize,
)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1, got %d", len(msgs))
}
}

View File

@@ -1,15 +1,29 @@
-- Chat server schema (pre-1.0 consolidated)
PRAGMA foreign_keys = ON;
-- Users: IRC-style sessions (no passwords, just nick + token)
CREATE TABLE IF NOT EXISTS users (
-- Sessions: each session is a user identity (nick + optional password + signing key)
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(uuid);
-- Clients: each session can have multiple connected clients
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
CREATE INDEX IF NOT EXISTS idx_clients_token ON clients(token);
CREATE INDEX IF NOT EXISTS idx_clients_session ON clients(session_id);
-- Channels
CREATE TABLE IF NOT EXISTS channels (
@@ -24,9 +38,9 @@ CREATE TABLE IF NOT EXISTS channels (
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, user_id)
UNIQUE(channel_id, session_id)
);
-- Messages: IRC envelope format
@@ -46,9 +60,9 @@ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, message_id)
UNIQUE(client_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id);
CREATE INDEX IF NOT EXISTS idx_client_queues_client ON client_queues(client_id, id);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

186
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,186 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
)
const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.deliverMOTD(request, clientID, sessionID)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleLogin(writer, request)
}
}
func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter,
request *http.Request,
) {
type loginRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload loginRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if payload.Nick == "" || payload.Password == "" {
hdlr.respondError(
writer, request,
"nick and password required",
http.StatusBadRequest,
)
return
}
sessionID, _, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid credentials",
http.StatusUnauthorized,
)
return
}
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusOK)
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"log/slog"
"net/http"
"time"
"git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/chat/internal/config"
@@ -30,50 +31,173 @@ type Params struct {
Healthcheck *healthcheck.Healthcheck
}
const defaultIdleTimeout = 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
cancelCleanup context.CancelFunc
}
// New creates a new Handlers instance.
func New(
lc fx.Lifecycle,
lifecycle fx.Lifecycle,
params Params,
) (*Handlers, error) {
s := new(Handlers)
s.params = &params
s.log = params.Logger.Get()
s.hc = params.Healthcheck
s.broker = broker.New()
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
hdlr.startCleanup(ctx)
return nil
},
OnStop: func(_ context.Context) error {
hdlr.stopCleanup()
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
return nil
},
})
return s, nil
return hdlr, nil
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
func (hdlr *Handlers) respondJSON(
writer http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
w.Header().Set(
writer.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
w.WriteHeader(status)
writer.WriteHeader(status)
if data != nil {
err := json.NewEncoder(w).Encode(data)
err := json.NewEncoder(writer).Encode(data)
if err != nil {
s.log.Error("json encode error", "error", err)
hdlr.log.Error(
"json encode error", "error", err,
)
}
}
}
func (hdlr *Handlers) respondError(
writer http.ResponseWriter,
request *http.Request,
msg string,
status int,
) {
hdlr.respondJSON(
writer, request,
map[string]string{"error": msg},
status,
)
}
func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" {
return defaultIdleTimeout
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid SESSION_IDLE_TIMEOUT, using default",
"value", raw, "error", err,
)
return defaultIdleTimeout
}
return dur
}
// startCleanup launches the idle-user cleanup goroutine.
// We use context.Background rather than the OnStart ctx
// because the OnStart context is startup-scoped and would
// cancel the goroutine once all start hooks complete.
//
//nolint:contextcheck // intentional Background ctx
func (hdlr *Handlers) startCleanup(_ context.Context) {
cleanupCtx, cancel := context.WithCancel(
context.Background(),
)
hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx)
}
func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup()
}
}
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
timeout := hdlr.idleTimeout()
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hdlr.runCleanup(ctx, timeout)
case <-ctx.Done():
return
}
}
}
func (hdlr *Handlers) runCleanup(
ctx context.Context,
timeout time.Duration,
) {
cutoff := time.Now().Add(-timeout)
// Find sessions that will be orphaned so we can send
// QUIT notifications before deleting anything.
stale, err := hdlr.params.Database.
GetStaleOrphanSessions(ctx, cutoff)
if err != nil {
hdlr.log.Error(
"stale session lookup failed", "error", err,
)
}
for _, ss := range stale {
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
}
deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"user cleanup failed", "error", err,
)
return
}
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale users",
"deleted", deleted,
)
}
}

View File

@@ -7,9 +7,12 @@ import (
const httpStatusOK = 200
// HandleHealthCheck returns an HTTP handler for the health check endpoint.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
resp := s.hc.Healthcheck()
s.respondJSON(w, req, resp, httpStatusOK)
func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
resp := hdlr.hc.Healthcheck()
hdlr.respondJSON(writer, request, resp, httpStatusOK)
}
}

View File

@@ -33,14 +33,17 @@ type Healthcheck struct {
}
// New creates a new Healthcheck instance.
func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
s := new(Healthcheck)
s.params = &params
s.log = params.Logger.Get()
func New(
lifecycle fx.Lifecycle, params Params,
) (*Healthcheck, error) {
hcheck := &Healthcheck{ //nolint:exhaustruct // StartupTime set in OnStart
params: &params,
log: params.Logger.Get(),
}
lc.Append(fx.Hook{
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
s.StartupTime = time.Now()
hcheck.StartupTime = time.Now()
return nil
},
@@ -49,7 +52,7 @@ func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
},
})
return s, nil
return hcheck, nil
}
// Response is the JSON response returned by the health endpoint.
@@ -64,19 +67,18 @@ type Response struct {
}
// Healthcheck returns the current health status of the server.
func (s *Healthcheck) Healthcheck() *Response {
resp := &Response{
func (hcheck *Healthcheck) Healthcheck() *Response {
return &Response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(s.uptime().Seconds()),
UptimeHuman: s.uptime().String(),
Appname: s.params.Globals.Appname,
Version: s.params.Globals.Version,
UptimeSeconds: int64(hcheck.uptime().Seconds()),
UptimeHuman: hcheck.uptime().String(),
Appname: hcheck.params.Globals.Appname,
Version: hcheck.params.Globals.Version,
Maintenance: hcheck.params.Config.MaintenanceMode,
}
return resp
}
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
func (hcheck *Healthcheck) uptime() time.Duration {
return time.Since(hcheck.StartupTime)
}

View File

@@ -23,51 +23,56 @@ type Logger struct {
params Params
}
// New creates a new Logger with appropriate handler based on terminal detection.
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
l := new(Logger)
l.level = new(slog.LevelVar)
l.level.Set(slog.LevelInfo)
// New creates a new Logger with appropriate handler
// based on terminal detection.
func New(
_ fx.Lifecycle, params Params,
) (*Logger, error) {
logger := new(Logger)
logger.level = new(slog.LevelVar)
logger.level.Set(slog.LevelInfo)
tty := false
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true
}
var handler slog.Handler
if tty {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: l.level,
AddSource: true,
})
} else {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: l.level,
AddSource: true,
})
opts := &slog.HandlerOptions{ //nolint:exhaustruct // ReplaceAttr optional
Level: logger.level,
AddSource: true,
}
l.log = slog.New(handler)
l.params = params
var handler slog.Handler
if tty {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return l, nil
logger.log = slog.New(handler)
logger.params = params
return logger, nil
}
// EnableDebugLogging switches the log level to debug.
func (l *Logger) EnableDebugLogging() {
l.level.Set(slog.LevelDebug)
l.log.Debug("debug logging enabled", "debug", true)
func (logger *Logger) EnableDebugLogging() {
logger.level.Set(slog.LevelDebug)
logger.log.Debug(
"debug logging enabled", "debug", true,
)
}
// Get returns the underlying slog.Logger.
func (l *Logger) Get() *slog.Logger {
return l.log
func (logger *Logger) Get() *slog.Logger {
return logger.log
}
// Identify logs the application name and version at startup.
func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
func (logger *Logger) Identify() {
logger.log.Info("starting",
"appname", logger.params.Globals.Appname,
"version", logger.params.Globals.Version,
)
}

View File

@@ -11,7 +11,7 @@ import (
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/middleware"
chimw "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
@@ -38,25 +38,28 @@ type Middleware struct {
}
// New creates a new Middleware instance.
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
s := new(Middleware)
s.params = &params
s.log = params.Logger.Get()
func New(
_ fx.Lifecycle, params Params,
) (*Middleware, error) {
mware := &Middleware{
params: &params,
log: params.Logger.Get(),
}
return s, nil
return mware, nil
}
func ipFromHostPort(hp string) string {
h, _, err := net.SplitHostPort(hp)
func ipFromHostPort(hostPort string) string {
host, _, err := net.SplitHostPort(hostPort)
if err != nil {
return ""
}
if len(h) > 0 && h[0] == '[' {
return h[1 : len(h)-1]
if len(host) > 0 && host[0] == '[' {
return host[1 : len(host)-1]
}
return h
return host
}
type loggingResponseWriter struct {
@@ -65,9 +68,15 @@ type loggingResponseWriter struct {
statusCode int
}
// newLoggingResponseWriter wraps a ResponseWriter to capture the status code.
func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
return &loggingResponseWriter{w, http.StatusOK}
// newLoggingResponseWriter wraps a ResponseWriter
// to capture the status code.
func newLoggingResponseWriter(
writer http.ResponseWriter,
) *loggingResponseWriter {
return &loggingResponseWriter{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
@@ -76,43 +85,57 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
}
// Logging returns middleware that logs each HTTP request.
func (s *Middleware) Logging() func(http.Handler) http.Handler {
func (mware *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := newLoggingResponseWriter(w)
ctx := r.Context()
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
start := time.Now()
lrw := newLoggingResponseWriter(writer)
ctx := request.Context()
defer func() {
latency := time.Since(start)
defer func() {
latency := time.Since(start)
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
reqID, _ := ctx.Value(
chimw.RequestIDKey,
).(string)
s.log.InfoContext(ctx, "request",
"request_start", start,
"method", r.Method,
"url", r.URL.String(),
"useragent", r.UserAgent(),
"request_id", reqID,
"referer", r.Referer(),
"proto", r.Proto,
"remoteIP", ipFromHostPort(r.RemoteAddr),
"status", lrw.statusCode,
"latency_ms", latency.Milliseconds(),
)
}()
mware.log.InfoContext(
ctx, "request",
"request_start", start,
"method", request.Method,
"url", request.URL.String(),
"useragent", request.UserAgent(),
"request_id", reqID,
"referer", request.Referer(),
"proto", request.Proto,
"remoteIP",
ipFromHostPort(request.RemoteAddr),
"status", lrw.statusCode,
"latency_ms",
latency.Milliseconds(),
)
}()
next.ServeHTTP(lrw, r)
})
next.ServeHTTP(lrw, request)
})
}
}
// CORS returns middleware that handles Cross-Origin Resource Sharing.
func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: corsMaxAge,
@@ -120,28 +143,34 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
}
// Auth returns middleware that performs authentication.
func (s *Middleware) Auth() func(http.Handler) http.Handler {
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.log.Info("AUTH: before request")
next.ServeHTTP(w, r)
})
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics.
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{}),
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
Recorder: metrics.NewRecorder(
metrics.Config{}, //nolint:exhaustruct // defaults
),
})
return func(next http.Handler) http.Handler {
return std.Handler("", mdlw, next)
return std.Handler("", metricsMiddleware, next)
}
}
// MetricsAuth returns middleware that protects metrics with basic auth.
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
return basicauth.New(
"metrics",
map[string][]string{

View File

@@ -17,67 +17,109 @@ import (
const routeTimeout = 60 * time.Second
// SetupRoutes configures the HTTP routes and middleware.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
func (srv *Server) SetupRoutes() {
srv.router = chi.NewRouter()
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging())
srv.router.Use(middleware.Recoverer)
srv.router.Use(middleware.RequestID)
srv.router.Use(srv.mw.Logging())
if viper.GetString("METRICS_USERNAME") != "" {
s.router.Use(s.mw.Metrics())
srv.router.Use(srv.mw.Metrics())
}
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(routeTimeout))
srv.router.Use(srv.mw.CORS())
srv.router.Use(middleware.Timeout(routeTimeout))
if s.sentryEnabled {
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
s.router.Use(sentryHandler.Handle)
if srv.sentryEnabled {
sentryHandler := sentryhttp.New(
sentryhttp.Options{ //nolint:exhaustruct // optional fields
Repanic: true,
},
)
srv.router.Use(sentryHandler.Handle)
}
// Health check
s.router.Get(
// Health check.
srv.router.Get(
"/.well-known/healthcheck.json",
s.h.HandleHealthCheck(),
srv.handlers.HandleHealthCheck(),
)
// Protected metrics endpoint
// Protected metrics endpoint.
if viper.GetString("METRICS_USERNAME") != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics",
srv.router.Group(func(router chi.Router) {
router.Use(srv.mw.MetricsAuth())
router.Get("/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
))
})
}
// API v1
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/server", s.h.HandleServerInfo())
r.Post("/session", s.h.HandleCreateSession())
r.Get("/state", s.h.HandleState())
r.Get("/messages", s.h.HandleGetMessages())
r.Post("/messages", s.h.HandleSendCommand())
r.Get("/history", s.h.HandleGetHistory())
r.Get("/channels", s.h.HandleListAllChannels())
r.Get(
"/channels/{channel}/members",
s.h.HandleChannelMembers(),
)
})
// API v1.
srv.router.Route("/api/v1", srv.setupAPIv1)
// Serve embedded SPA
s.setupSPA()
// Serve embedded SPA.
srv.setupSPA()
}
func (s *Server) setupSPA() {
func (srv *Server) setupAPIv1(router chi.Router) {
router.Get(
"/server",
srv.handlers.HandleServerInfo(),
)
router.Post(
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),
)
router.Get(
"/state",
srv.handlers.HandleState(),
)
router.Post(
"/logout",
srv.handlers.HandleLogout(),
)
router.Get(
"/users/me",
srv.handlers.HandleUsersMe(),
)
router.Get(
"/messages",
srv.handlers.HandleGetMessages(),
)
router.Post(
"/messages",
srv.handlers.HandleSendCommand(),
)
router.Get(
"/history",
srv.handlers.HandleGetHistory(),
)
router.Get(
"/channels",
srv.handlers.HandleListAllChannels(),
)
router.Get(
"/channels/{channel}/members",
srv.handlers.HandleChannelMembers(),
)
}
func (srv *Server) setupSPA() {
distFS, err := fs.Sub(web.Dist, "dist")
if err != nil {
s.log.Error(
srv.log.Error(
"failed to get web dist filesystem",
"error", err,
)
@@ -87,38 +129,40 @@ func (s *Server) setupSPA() {
fileServer := http.FileServer(http.FS(distFS))
s.router.Get("/*", func(
w http.ResponseWriter,
r *http.Request,
srv.router.Get("/*", func(
writer http.ResponseWriter,
request *http.Request,
) {
readFS, ok := distFS.(fs.ReadFileFS)
if !ok {
fileServer.ServeHTTP(w, r)
fileServer.ServeHTTP(writer, request)
return
}
f, readErr := readFS.ReadFile(r.URL.Path[1:])
if readErr != nil || len(f) == 0 {
fileData, readErr := readFS.ReadFile(
request.URL.Path[1:],
)
if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile(
"index.html",
)
if indexErr != nil {
http.NotFound(w, r)
http.NotFound(writer, request)
return
}
w.Header().Set(
writer.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexHTML)
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(indexHTML)
return
}
fileServer.ServeHTTP(w, r)
fileServer.ServeHTTP(writer, request)
})
}

View File

@@ -41,7 +41,8 @@ type Params struct {
Handlers *handlers.Handlers
}
// Server is the main HTTP server. It manages routing, middleware, and lifecycle.
// Server is the main HTTP server.
// It manages routing, middleware, and lifecycle.
type Server struct {
startupTime time.Time
exitCode int
@@ -53,21 +54,24 @@ type Server struct {
router *chi.Mux
params Params
mw *middleware.Middleware
h *handlers.Handlers
handlers *handlers.Handlers
}
// New creates a new Server and registers its lifecycle hooks.
func New(lc fx.Lifecycle, params Params) (*Server, error) {
s := new(Server)
s.params = params
s.mw = params.Middleware
s.h = params.Handlers
s.log = params.Logger.Get()
func New(
lifecycle fx.Lifecycle, params Params,
) (*Server, error) {
srv := &Server{ //nolint:exhaustruct // fields set during lifecycle
params: params,
mw: params.Middleware,
handlers: params.Handlers,
log: params.Logger.Get(),
}
lc.Append(fx.Hook{
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
s.startupTime = time.Now()
go s.Run() //nolint:contextcheck
srv.startupTime = time.Now()
go srv.Run() //nolint:contextcheck
return nil
},
@@ -76,122 +80,140 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
},
})
return s, nil
return srv, nil
}
// Run starts the server configuration, Sentry, and begins serving.
func (s *Server) Run() {
s.configure()
s.enableSentry()
s.serve()
func (srv *Server) Run() {
srv.configure()
srv.enableSentry()
srv.serve()
}
// ServeHTTP delegates to the chi router.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
func (srv *Server) ServeHTTP(
writer http.ResponseWriter,
request *http.Request,
) {
srv.router.ServeHTTP(writer, request)
}
// MaintenanceMode reports whether the server is in maintenance mode.
func (s *Server) MaintenanceMode() bool {
return s.params.Config.MaintenanceMode
func (srv *Server) MaintenanceMode() bool {
return srv.params.Config.MaintenanceMode
}
func (s *Server) enableSentry() {
s.sentryEnabled = false
func (srv *Server) enableSentry() {
srv.sentryEnabled = false
if s.params.Config.SentryDSN == "" {
if srv.params.Config.SentryDSN == "" {
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: s.params.Config.SentryDSN,
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
err := sentry.Init(sentry.ClientOptions{ //nolint:exhaustruct // only essential fields
Dsn: srv.params.Config.SentryDSN,
Release: fmt.Sprintf(
"%s-%s",
srv.params.Globals.Appname,
srv.params.Globals.Version,
),
})
if err != nil {
s.log.Error("sentry init failure", "error", err)
srv.log.Error("sentry init failure", "error", err)
os.Exit(1)
}
s.log.Info("sentry error reporting activated")
s.sentryEnabled = true
srv.log.Info("sentry error reporting activated")
srv.sentryEnabled = true
}
func (s *Server) serve() int {
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
c := make(chan os.Signal, 1)
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
if s.cancelFunc != nil {
s.cancelFunc()
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go s.serveUntilShutdown()
go srv.serveUntilShutdown()
<-s.ctx.Done()
<-srv.ctx.Done()
s.cleanShutdown()
srv.cleanShutdown()
return s.exitCode
return srv.exitCode
}
func (s *Server) cleanupForExit() {
s.log.Info("cleaning up")
func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up")
}
func (s *Server) cleanShutdown() {
s.exitCode = 0
func (srv *Server) cleanShutdown() {
srv.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout,
)
err := s.httpServer.Shutdown(ctxShutdown)
err := srv.httpServer.Shutdown(ctxShutdown)
if err != nil {
s.log.Error("server clean shutdown failed", "error", err)
srv.log.Error(
"server clean shutdown failed", "error", err,
)
}
if shutdownCancel != nil {
shutdownCancel()
}
s.cleanupForExit()
srv.cleanupForExit()
if s.sentryEnabled {
if srv.sentryEnabled {
sentry.Flush(sentryFlushTime)
}
}
func (s *Server) configure() {
// server configuration placeholder
func (srv *Server) configure() {
// Server configuration placeholder.
}
func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{
func (srv *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port,
)
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
MaxHeaderBytes: maxHeaderBytes,
Handler: s,
Handler: srv,
}
s.SetupRoutes()
srv.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", listenAddr)
srv.log.Info(
"http begin listen", "listenaddr", listenAddr,
)
err := s.httpServer.ListenAndServe()
err := srv.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err)
srv.log.Error("listen error", "error", err)
if s.cancelFunc != nil {
s.cancelFunc()
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}
}

466
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

43
web/dist/style.css vendored
View File

@@ -14,6 +14,9 @@
--tab-active: #e94560;
--tab-bg: #16213e;
--tab-hover: #1a1a3e;
--topic-bg: #121a30;
--unread-bg: #e94560;
--warn: #f0ad4e;
}
html, body, #root {
@@ -86,6 +89,7 @@ html, body, #root {
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
align-items: center;
}
.tab {
@@ -95,6 +99,7 @@ html, body, #root {
white-space: nowrap;
color: var(--text-muted);
user-select: none;
position: relative;
}
.tab:hover {
@@ -116,6 +121,43 @@ html, body, #root {
color: var(--accent);
}
.tab .unread-badge {
display: inline-block;
background: var(--unread-bg);
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
}
/* Connection status */
.connection-status {
padding: 4px 12px;
background: var(--warn);
color: #1a1a2e;
font-size: 12px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
}
/* Topic bar */
.topic-bar {
padding: 6px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
/* Content area */
.content {
display: flex;
@@ -243,6 +285,7 @@ html, body, #root {
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
}
.join-dialog input {

View File

@@ -1,13 +1,17 @@
import { h, render, Component } from 'preact';
import { h, render } from 'preact';
import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
const API = '/api/v1';
const POLL_TIMEOUT = 15;
const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000;
function api(path, opts = {}) {
const token = localStorage.getItem('chat_token');
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(API + path, { ...opts, headers }).then(async r => {
const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async r => {
const data = await r.json().catch(() => null);
if (!r.ok) throw { status: r.status, data };
return data;
@@ -19,7 +23,6 @@ function formatTime(ts) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// Nick color hashing
function nickColor(nick) {
let h = 0;
for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h);
@@ -39,10 +42,9 @@ function LoginScreen({ onLogin }) {
if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd);
}).catch(() => {});
// Check for saved token
const saved = localStorage.getItem('chat_token');
if (saved) {
api('/state').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token'));
api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('chat_token'));
}
inputRef.current?.focus();
}, []);
@@ -56,7 +58,7 @@ function LoginScreen({ onLogin }) {
body: JSON.stringify({ nick: nick.trim() })
});
localStorage.setItem('chat_token', res.token);
onLogin(res.nick, res.token);
onLogin(res.nick);
} catch (err) {
setError(err.data?.error || 'Connection failed');
}
@@ -84,11 +86,19 @@ function LoginScreen({ onLogin }) {
}
function Message({ msg }) {
if (msg.system) {
return (
<div class="message system">
<span class="timestamp">{formatTime(msg.ts)}</span>
<span class="content">{msg.text}</span>
</div>
);
}
return (
<div class={`message ${msg.system ? 'system' : ''}`}>
<span class="timestamp">{formatTime(msg.createdAt)}</span>
<span class="nick" style={{ color: msg.system ? undefined : nickColor(msg.nick) }}>{msg.nick}</span>
<span class="content">{msg.content}</span>
<div class="message">
<span class="timestamp">{formatTime(msg.ts)}</span>
<span class="nick" style={{ color: nickColor(msg.from) }}>{msg.from}</span>
<span class="content">{msg.text}</span>
</div>
);
}
@@ -98,93 +108,194 @@ function App() {
const [nick, setNick] = useState('');
const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]);
const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ server: [] }); // keyed by tab name
const [members, setMembers] = useState({}); // keyed by channel name
const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({});
const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({});
const [input, setInput] = useState('');
const [joinInput, setJoinInput] = useState('');
const [lastMsgId, setLastMsgId] = useState(0);
const [connected, setConnected] = useState(true);
const lastIdRef = useRef(0);
const seenIdsRef = useRef(new Set());
const pollAbortRef = useRef(null);
const tabsRef = useRef(tabs);
const activeTabRef = useRef(activeTab);
const nickRef = useRef(nick);
const messagesEndRef = useRef();
const inputRef = useRef();
const pollRef = useRef();
useEffect(() => { tabsRef.current = tabs; }, [tabs]);
useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
useEffect(() => { nickRef.current = nick; }, [nick]);
// Persist joined channels
useEffect(() => {
const channels = tabs.filter(t => t.type === 'channel').map(t => t.name);
localStorage.setItem('chat_channels', JSON.stringify(channels));
}, [tabs]);
// Clear unread on tab switch
useEffect(() => {
const tab = tabs[activeTab];
if (tab) setUnread(prev => ({ ...prev, [tab.name]: 0 }));
}, [activeTab, tabs]);
const addMessage = useCallback((tabName, msg) => {
if (msg.id && seenIdsRef.current.has(msg.id)) return;
if (msg.id) seenIdsRef.current.add(msg.id);
setMessages(prev => ({
...prev,
[tabName]: [...(prev[tabName] || []), msg]
}));
const currentTab = tabsRef.current[activeTabRef.current];
if (!currentTab || currentTab.name !== tabName) {
setUnread(prev => ({ ...prev, [tabName]: (prev[tabName] || 0) + 1 }));
}
}, []);
const addSystemMessage = useCallback((tabName, text) => {
addMessage(tabName, {
id: Date.now(),
nick: '*',
content: text,
createdAt: new Date().toISOString(),
system: true
});
}, [addMessage]);
setMessages(prev => ({
...prev,
[tabName]: [...(prev[tabName] || []), {
id: 'sys-' + Date.now() + '-' + Math.random(),
ts: new Date().toISOString(),
text,
system: true
}]
}));
}, []);
const onLogin = useCallback((userNick, token) => {
setNick(userNick);
setLoggedIn(true);
addSystemMessage('server', `Connected as ${userNick}`);
// Fetch server info
api('/server').then(s => {
if (s.motd) addSystemMessage('server', `MOTD: ${s.motd}`);
const refreshMembers = useCallback((channel) => {
const chName = channel.replace('#', '');
api(`/channels/${chName}/members`).then(m => {
setMembers(prev => ({ ...prev, [channel]: m }));
}).catch(() => {});
}, [addSystemMessage]);
}, []);
// Poll for new messages
const processMessage = useCallback((msg) => {
const body = Array.isArray(msg.body) ? msg.body.join('\n') : '';
const base = { id: msg.id, ts: msg.ts, from: msg.from, to: msg.to, command: msg.command };
switch (msg.command) {
case 'PRIVMSG':
case 'NOTICE': {
const parsed = { ...base, text: body, system: false };
const target = msg.to;
if (target && target.startsWith('#')) {
addMessage(target, parsed);
} else {
const dmPeer = msg.from === nickRef.current ? msg.to : msg.from;
setTabs(prev => {
if (!prev.find(t => t.type === 'dm' && t.name === dmPeer)) {
return [...prev, { type: 'dm', name: dmPeer }];
}
return prev;
});
addMessage(dmPeer, parsed);
}
break;
}
case 'JOIN': {
const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to);
break;
}
case 'PART': {
const reason = body ? ': ' + body : '';
const text = `${msg.from} has left ${msg.to}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to);
break;
}
case 'QUIT': {
const reason = body ? ': ' + body : '';
const text = `${msg.from} has quit${reason}`;
tabsRef.current.forEach(tab => {
if (tab.type === 'channel') {
addMessage(tab.name, { ...base, text, system: true });
}
});
break;
}
case 'NICK': {
const newNick = Array.isArray(msg.body) ? msg.body[0] : body;
const text = `${msg.from} is now known as ${newNick}`;
tabsRef.current.forEach(tab => {
if (tab.type === 'channel') {
addMessage(tab.name, { ...base, text, system: true });
}
});
if (msg.from === nickRef.current && newNick) setNick(newNick);
// Refresh members in all channels
tabsRef.current.forEach(tab => {
if (tab.type === 'channel') refreshMembers(tab.name);
});
break;
}
case 'TOPIC': {
const text = `${msg.from} set the topic: ${body}`;
if (msg.to) {
addMessage(msg.to, { ...base, text, system: true });
setTopics(prev => ({ ...prev, [msg.to]: body }));
}
break;
}
case '375':
case '372':
case '376':
addMessage('Server', { ...base, text: body, system: true });
break;
default:
addMessage('Server', { ...base, text: body || msg.command, system: true });
}
}, [addMessage, refreshMembers]);
// Long-poll loop
useEffect(() => {
if (!loggedIn) return;
let alive = true;
const poll = async () => {
try {
const msgs = await api(`/messages?after=${lastMsgId}`);
if (!alive) return;
let maxId = lastMsgId;
for (const msg of msgs) {
if (msg.id > maxId) maxId = msg.id;
if (msg.isDm) {
const dmTab = msg.nick === nick ? msg.dmTarget : msg.nick;
// Ensure DM tab exists
setTabs(prev => {
if (!prev.find(t => t.type === 'dm' && t.name === dmTab)) {
return [...prev, { type: 'dm', name: dmTab }];
}
return prev;
});
addMessage(dmTab, msg);
} else if (msg.channel) {
addMessage(msg.channel, msg);
while (alive) {
try {
const controller = new AbortController();
pollAbortRef.current = controller;
const result = await api(
`/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`,
{ signal: controller.signal }
);
if (!alive) break;
setConnected(true);
if (result.messages) {
for (const m of result.messages) processMessage(m);
}
if (result.last_id > lastIdRef.current) {
lastIdRef.current = result.last_id;
}
} catch (err) {
if (!alive) break;
if (err.name === 'AbortError') continue;
setConnected(false);
await new Promise(r => setTimeout(r, RECONNECT_DELAY));
}
if (maxId > lastMsgId) setLastMsgId(maxId);
} catch (err) {
// silent
}
};
pollRef.current = setInterval(poll, 1500);
poll();
return () => { alive = false; clearInterval(pollRef.current); };
}, [loggedIn, lastMsgId, nick, addMessage]);
// Fetch members for active channel tab
poll();
return () => { alive = false; pollAbortRef.current?.abort(); };
}, [loggedIn, processMessage]);
// Refresh members for active channel
useEffect(() => {
if (!loggedIn) return;
const tab = tabs[activeTab];
if (!tab || tab.type !== 'channel') return;
const chName = tab.name.replace('#', '');
api(`/channels/${chName}/members`).then(m => {
setMembers(prev => ({ ...prev, [tab.name]: m }));
}).catch(() => {});
const iv = setInterval(() => {
api(`/channels/${chName}/members`).then(m => {
setMembers(prev => ({ ...prev, [tab.name]: m }));
}).catch(() => {});
}, 5000);
refreshMembers(tab.name);
const iv = setInterval(() => refreshMembers(tab.name), MEMBER_REFRESH_INTERVAL);
return () => clearInterval(iv);
}, [loggedIn, activeTab, tabs]);
}, [loggedIn, activeTab, tabs, refreshMembers]);
// Auto-scroll
useEffect(() => {
@@ -192,9 +303,37 @@ function App() {
}, [messages, activeTab]);
// Focus input on tab change
useEffect(() => { inputRef.current?.focus(); }, [activeTab]);
// Fetch topic for active channel
useEffect(() => {
inputRef.current?.focus();
}, [activeTab]);
if (!loggedIn) return;
const tab = tabs[activeTab];
if (!tab || tab.type !== 'channel') return;
api('/channels').then(channels => {
const ch = channels.find(c => c.name === tab.name);
if (ch && ch.topic) setTopics(prev => ({ ...prev, [tab.name]: ch.topic }));
}).catch(() => {});
}, [loggedIn, activeTab, tabs]);
const onLogin = useCallback(async (userNick) => {
setNick(userNick);
setLoggedIn(true);
addSystemMessage('Server', `Connected as ${userNick}`);
// Auto-rejoin saved channels
const saved = JSON.parse(localStorage.getItem('chat_channels') || '[]');
for (const ch of saved) {
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) });
setTabs(prev => {
if (prev.find(t => t.type === 'channel' && t.name === ch)) return prev;
return [...prev, { type: 'channel', name: ch }];
});
} catch (e) {
// Channel may not exist anymore
}
}
}, [addSystemMessage]);
const joinChannel = async (name) => {
if (!name) return;
@@ -206,22 +345,29 @@ function App() {
if (prev.find(t => t.type === 'channel' && t.name === name)) return prev;
return [...prev, { type: 'channel', name }];
});
setActiveTab(tabs.length); // switch to new tab
addSystemMessage(name, `Joined ${name}`);
setActiveTab(tabs.length);
// Load history
try {
const hist = await api(`/history?target=${encodeURIComponent(name)}&limit=50`);
if (Array.isArray(hist)) {
for (const m of hist) processMessage(m);
}
} catch (e) {
// History may be empty
}
setJoinInput('');
} catch (err) {
addSystemMessage('server', `Failed to join ${name}: ${err.data?.error || 'error'}`);
addSystemMessage('Server', `Failed to join ${name}: ${err.data?.error || 'error'}`);
}
};
const partChannel = async (name) => {
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) });
} catch (err) { /* ignore */ }
setTabs(prev => {
const next = prev.filter(t => !(t.type === 'channel' && t.name === name));
return next;
});
} catch (e) {
// Ignore
}
setTabs(prev => prev.filter(t => !(t.type === 'channel' && t.name === name)));
setActiveTab(0);
};
@@ -240,7 +386,8 @@ function App() {
if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev;
return [...prev, { type: 'dm', name: targetNick }];
});
setActiveTab(tabs.findIndex(t => t.type === 'dm' && t.name === targetNick) || tabs.length);
const idx = tabs.findIndex(t => t.type === 'dm' && t.name === targetNick);
setActiveTab(idx >= 0 ? idx : tabs.length);
};
const sendMessage = async () => {
@@ -250,46 +397,45 @@ function App() {
const tab = tabs[activeTab];
if (!tab || tab.type === 'server') return;
// Handle /commands
if (text.startsWith('/')) {
const parts = text.split(' ');
const cmd = parts[0].toLowerCase();
if (cmd === '/join' && parts[1]) {
joinChannel(parts[1]);
return;
}
if (cmd === '/part') {
if (tab.type === 'channel') partChannel(tab.name);
return;
}
if (cmd === '/join' && parts[1]) { joinChannel(parts[1]); return; }
if (cmd === '/part') { if (tab.type === 'channel') partChannel(tab.name); return; }
if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) {
const target = parts[1];
const msg = parts.slice(2).join(' ');
const body = parts.slice(2).join(' ');
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [msg] }) });
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [body] }) });
openDM(target);
} catch (err) {
addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`);
addSystemMessage('Server', `DM failed: ${err.data?.error || 'error'}`);
}
return;
}
if (cmd === '/nick' && parts[1]) {
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) });
setNick(parts[1]);
addSystemMessage('server', `Nick changed to ${parts[1]}`);
} catch (err) {
addSystemMessage('server', `Nick change failed: ${err.data?.error || 'error'}`);
addSystemMessage('Server', `Nick change failed: ${err.data?.error || 'error'}`);
}
return;
}
addSystemMessage('server', `Unknown command: ${cmd}`);
if (cmd === '/topic' && tab.type === 'channel') {
const topicText = parts.slice(1).join(' ');
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'TOPIC', to: tab.name, body: [topicText] }) });
} catch (err) {
addSystemMessage('Server', `Topic failed: ${err.data?.error || 'error'}`);
}
return;
}
addSystemMessage('Server', `Unknown command: ${cmd}`);
return;
}
const to = tab.type === 'channel' ? tab.name : tab.name;
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to, body: [text] }) });
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: tab.name, body: [text] }) });
} catch (err) {
addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`);
}
@@ -300,16 +446,21 @@ function App() {
const currentTab = tabs[activeTab] || tabs[0];
const currentMessages = messages[currentTab.name] || [];
const currentMembers = members[currentTab.name] || [];
const currentTopic = topics[currentTab.name] || '';
return (
<div class="app">
<div class="tab-bar">
{!connected && <div class="connection-status"> Reconnecting...</div>}
{tabs.map((tab, i) => (
<div
class={`tab ${i === activeTab ? 'active' : ''}`}
onClick={() => setActiveTab(i)}
>
{tab.type === 'dm' ? `${tab.name}` : tab.name}
{unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-badge">{unread[tab.name]}</span>
)}
{tab.type !== 'server' && (
<span class="close-btn" onClick={(e) => { e.stopPropagation(); closeTab(i); }}>×</span>
)}
@@ -326,30 +477,27 @@ function App() {
</div>
</div>
{currentTab.type === 'channel' && currentTopic && (
<div class="topic-bar" title={currentTopic}>{currentTopic}</div>
)}
<div class="content">
<div class="messages-pane">
{currentTab.type === 'server' ? (
<div class="server-messages">
{currentMessages.map(m => <Message msg={m} />)}
<div ref={messagesEndRef} />
<div class={currentTab.type === 'server' ? 'server-messages' : 'messages'}>
{currentMessages.map(m => <Message msg={m} />)}
<div ref={messagesEndRef} />
</div>
{currentTab.type !== 'server' && (
<div class="input-bar">
<input
ref={inputRef}
placeholder={`Message ${currentTab.name}...`}
value={input}
onInput={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
</div>
) : (
<>
<div class="messages">
{currentMessages.map(m => <Message msg={m} />)}
<div ref={messagesEndRef} />
</div>
<div class="input-bar">
<input
ref={inputRef}
placeholder={`Message ${currentTab.name}...`}
value={input}
onInput={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
</div>
</>
)}
</div>

View File

@@ -14,6 +14,9 @@
--tab-active: #e94560;
--tab-bg: #16213e;
--tab-hover: #1a1a3e;
--topic-bg: #121a30;
--unread-bg: #e94560;
--warn: #f0ad4e;
}
html, body, #root {
@@ -86,6 +89,7 @@ html, body, #root {
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
align-items: center;
}
.tab {
@@ -95,6 +99,7 @@ html, body, #root {
white-space: nowrap;
color: var(--text-muted);
user-select: none;
position: relative;
}
.tab:hover {
@@ -116,6 +121,43 @@ html, body, #root {
color: var(--accent);
}
.tab .unread-badge {
display: inline-block;
background: var(--unread-bg);
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
}
/* Connection status */
.connection-status {
padding: 4px 12px;
background: var(--warn);
color: #1a1a2e;
font-size: 12px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
}
/* Topic bar */
.topic-bar {
padding: 6px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
/* Content area */
.content {
display: flex;
@@ -243,6 +285,7 @@ html, body, #root {
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
}
.join-dialog input {