diff --git a/.dockerignore b/.dockerignore index d1d7a22..5004937 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,9 @@ .git -node_modules -.DS_Store -bin/ +*.md +!README.md +chatd +chat-cli data.db +data.db-wal +data.db-shm .env -*.test -*.out -debug.log diff --git a/.golangci.yml b/.golangci.yml index 34a8e31..2698d2d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,24 +7,28 @@ run: linters: default: all disable: - # Genuinely incompatible with project patterns - - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists - - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - - wrapcheck # Too verbose for internal packages - - varnamelen # Short names like db, id are idiomatic Go - -linters-settings: - lll: - line-length: 88 - funlen: - lines: 80 - statements: 50 - cyclop: - max-complexity: 15 - dupl: - threshold: 100 + - wsl # Deprecated in v2, replaced by wsl_v5 + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + 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 diff --git a/Dockerfile b/Dockerfile index b9b149a..2dd9992 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,32 @@ # golang:1.24-alpine, 2026-02-26 FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder - -RUN apk add --no-cache git build-base - WORKDIR /src +RUN apk add --no-cache git build-base make + +# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26 +RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d + COPY go.mod go.sum ./ RUN go mod download COPY . . # Run all checks — build fails if branch is not green -RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.1.6 RUN make check +# Build binaries ARG VERSION=dev -RUN go build -ldflags "-X main.Version=${VERSION}" -o /chatd ./cmd/chatd +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/ # alpine:3.21, 2026-02-26 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 - -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates \ + && addgroup -S chat && adduser -S chat -G chat COPY --from=builder /chatd /usr/local/bin/chatd -WORKDIR /data +USER chat EXPOSE 8080 - +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1 ENTRYPOINT ["chatd"] diff --git a/README.md b/README.md index 267011c..4ba7b4c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,43 @@ # chat -**IRC plus message metadata, a signing system using it, and server-based -backlog queues for multiple connected clients on one nick. All via HTTP.** +**IRC semantics, structured message metadata, cryptographic signing, and +server-held session state with per-client delivery queues. All over HTTP+JSON.** -A chat server written in Go. Decouples session state from transport -connections, enabling mobile-friendly persistent sessions over HTTP. +A chat server written in Go that decouples session state from transport +connections, enabling mobile-friendly persistent sessions over plain HTTP. The **HTTP API is the primary interface**. It's designed to be simple enough that writing a terminal IRC-style client against it is straightforward — just `curl` and `jq` get you surprisingly far. The server also ships an embedded web client as a convenience/reference implementation, but the API comes first. +--- + +## Table of Contents + +- [Motivation](#motivation) +- [Why Not IRC / XMPP / Matrix?](#why-not-just-use-irc--xmpp--matrix) +- [Design Decisions](#design-decisions) +- [Architecture](#architecture) +- [Protocol Specification](#protocol-specification) +- [API Reference](#api-reference) +- [Message Flow](#message-flow) +- [Canonicalization & Signing](#canonicalization-and-signing) +- [Security Model](#security-model) +- [Federation](#federation-server-to-server) +- [Storage](#storage) +- [Configuration](#configuration) +- [Deployment](#deployment) +- [Client Development Guide](#client-development-guide) +- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention) +- [Roadmap](#roadmap) +- [Project Structure](#project-structure) +- [Design Principles](#design-principles) +- [Status](#status) +- [License](#license) + +--- + ## Motivation IRC is in decline because session state is tied to the TCP connection. In a @@ -24,6 +51,14 @@ This project builds a chat server that: - Supports multiple concurrent clients per user session - Provides IRC-like semantics: channels, nicks, topics, modes - Uses structured JSON messages with IRC command names and numeric reply codes +- Enables optional cryptographic message signing with deterministic + canonicalization + +The entire client read/write loop is two HTTP endpoints. If a developer can't +build a working IRC-style TUI client against this API in an afternoon, the API +is too complex. + +--- ## Why Not Just Use IRC / XMPP / Matrix? @@ -83,7 +118,7 @@ display name. ### On the resemblance to JSON-RPC -All C2S commands go through `POST /messages` with a `command` field that +All C2S commands go through `POST /api/v1/messages` with a `command` field that dispatches the action. This looks like JSON-RPC, but the resemblance is incidental. It's IRC's command model — `PRIVMSG #channel :hello` becomes `{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as @@ -104,20 +139,54 @@ they're solving different problems at different scales. This project wants IRC's simplicity with four specific fixes. That's it. +--- + ## Design Decisions +This section documents every major design decision and its rationale. These are +not arbitrary choices — each one follows from the project's core thesis that +IRC's command model is correct and only the transport and session management +need to change. + ### Identity & Sessions — No Accounts There are no accounts, no registration, no passwords. Identity is a signing key; a nick is just a display name. The two are decoupled. -- **Session creation**: client connects → server assigns a **session UUID** - (user identity for this server), a **client UUID** (this specific device), - and an **opaque auth token** (random bytes, not JWT). +- **Session creation**: client sends `POST /api/v1/session` with a desired + nick → server assigns an **auth token** (64 hex characters of + cryptographically random bytes) and returns the user ID, nick, and token. - The auth token implicitly identifies the client. Clients present it via `Authorization: Bearer `. -- Nicks are changeable; the session UUID is the stable identity. -- Server-assigned UUIDs — clients do not choose their own IDs. +- Nicks are changeable via the `NICK` command; the server-assigned user ID is + the stable identity. +- Server-assigned IDs — clients do not choose their own IDs. +- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded + in the token, no client-side decode. The server is the sole authority on + token validity. + +**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding +registration, email verification, or OAuth would solve a problem nobody asked +about and add complexity that drives away casual users. Identity verification +is handled at the message layer via cryptographic signatures (see +[Security Model](#security-model)), not at the session layer. + +### Nick Semantics + +- Nicks are **unique per server at any point in time** — two sessions cannot + hold the same nick simultaneously. +- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` + are different nicks. +- Nick length: 1–32 characters. No further character restrictions in the + current implementation. +- Nicks are **released when a session is destroyed** (via `QUIT` command or + session expiry). There is no nick registration or reservation system. +- Nick changes are broadcast to all users sharing a channel with the changer, + as a `NICK` event message. + +**Rationale:** IRC nick semantics, simplified. Case-insensitive nick comparison +is a perpetual source of IRC bugs (different servers use different case-folding +rules). Case-sensitive comparison is unambiguous. ### Multi-Client Model @@ -132,474 +201,1901 @@ A single user session can have multiple clients (phone, laptop, terminal). doesn't affect others. ``` -User (session UUID) -├── Client A (client UUID, token, queue) -├── Client B (client UUID, token, queue) -└── Client C (client UUID, token, queue) +User Session +├── Client A (token_a, queue_a) +├── Client B (token_b, queue_b) +└── Client C (token_c, queue_c) ``` +**Current MVP note:** The current implementation creates a new user (with new +nick) per `POST /api/v1/session` call. True multi-client (multiple tokens +sharing one nick/session) is supported by the schema (`client_queues` is keyed +by user_id, and multiple tokens can point to the same user) but the session +creation endpoint does not yet support "add a client to an existing session." +This will be added post-MVP. + +**Rationale:** The fundamental IRC mobile problem is that you can't have your +phone and laptop connected simultaneously without a bouncer. Server-side +per-client queues solve this cleanly. + ### Message Immutability -Messages are **immutable** — no editing, no deletion by clients. This is a -deliberate design choice that enables cryptographic signing: if a message could -be modified after signing, signatures would be meaningless. +Messages are **immutable** — no editing, no deletion by clients. There are no +edit or delete API endpoints and there never will be. -### Message Delivery +**Rationale:** Cryptographic signing requires immutability. If a message could +be modified after signing, signatures would be meaningless. This is a feature, +not a limitation. Chat platforms that allow editing signed messages have +fundamentally broken their trust model. If you said something wrong, send a +correction — that's what IRC's culture has always been. -- **Long-poll timeout**: 15 seconds -- **Queue depth**: server-configurable, default at least 48 hours worth of - messages -- **No delivery/read receipts** except in DMs -- **Bodies are structured** objects or arrays (never raw strings) — enables - deterministic canonicalization via RFC 8785 JCS for signing +### Message Delivery Model -### Crypto & Signing +The server uses a **fan-out queue** model: -- Servers **relay signatures verbatim** — signatures are key/value metadata on - message objects (`meta.sig`, `meta.alg`). Servers do not verify them. -- Clients handle key authentication via **TOFU** (trust on first use). -- **No key revocation mechanism** — keep your keys safe. -- **PUBKEY** message type for distributing signing keys to channel members. -- **E2E encryption for DMs** is planned for 1.0. +1. Client sends a command (e.g., `PRIVMSG` to `#general`) +2. Server determines all recipients (all members of `#general`) +3. Server stores the message once in the `messages` table +4. Server creates one entry per recipient in the `client_queues` table +5. Server notifies all waiting long-poll connections for those recipients +6. Each recipient's next `GET /messages` poll returns the queued message + +Key properties: + +- **At-least-once delivery**: Messages are queued until the client polls for + them. The client advances its cursor (`after` parameter) to acknowledge + receipt. Messages are not deleted from the queue on read — the cursor-based + model means clients can re-read by providing an earlier `after` value. +- **Ordered**: Queue entries have monotonically increasing IDs. Messages are + always delivered in order within a client's queue. +- **No delivery/read receipts** for channel messages. DM receipts are planned. +- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48 + hours. Entries older than this are pruned. + +### Long-Polling + +The server implements HTTP long-polling for real-time message delivery: + +1. Client sends `GET /api/v1/messages?after=&timeout=15` +2. If messages are immediately available, server responds instantly +3. If no messages are available, server holds the connection open +4. Server responds when either: + - A message arrives for this client (via the in-memory broker) + - The timeout expires (returns empty array) + - The client disconnects (connection closed, no response needed) + +**Implementation detail:** The server maintains an in-memory broker with +per-user notification channels. When a message is enqueued for a user, the +broker closes all waiting channels for that user, waking up any blocked +long-poll handlers. This is O(1) notification — no polling loops, no database +scanning. + +**Timeout limits:** The server caps the `timeout` parameter at 30 seconds. +Clients should use 15 seconds as the default. The HTTP write timeout is set +to 60 seconds to accommodate long-poll connections. + +**Rationale:** Long-polling over HTTP is the simplest real-time transport that +works everywhere. WebSockets add connection state, require different proxy +configuration, break in some corporate firewalls, and don't work with standard +HTTP middleware. SSE (Server-Sent Events) is one-directional and poorly +supported by some HTTP client libraries. Long-polling is just regular HTTP +requests that sometimes take longer to respond. Every HTTP client, proxy, load +balancer, and CDN handles it correctly. ### Channels - **Any user can create channels** — joining a nonexistent channel creates it, - like IRC. -- **Ephemeral** — channels disappear when the last member leaves. -- No channel size limits. -- No channel-level encryption. + exactly like IRC. +- **Ephemeral** — channels disappear when the last member leaves. There is no + persistent channel registration. +- **No channel size limits** in the current implementation. +- **Channel names** must start with `#`. If a client sends a `JOIN` without + the `#` prefix, the server adds it. +- **No channel-level encryption** — encryption is per-message via the `meta` + field. -### Federation +### Direct Messages (DMs) -- **Manual server linking only** — no autodiscovery, no mesh. Operators - explicitly configure server links. -- Servers relay messages (including signatures) verbatim. +- DMs are addressed by **nick at send time** — the server resolves the nick + to a user ID internally. +- DMs are **fan-out to both sender and recipient** — the sender sees their own + DM echoed back in their message queue, enabling multi-client consistency + (your laptop sees DMs you sent from your phone). +- DM history is stored in the `messages` table with the recipient nick as the + `msg_to` field. This means DM history is queryable per-nick, but if a user + changes their nick, old DMs are associated with the old nick. +- DMs are **not stored long-term** by default — they follow the same rotation + policy as channel messages. -### Web Client +### JSON, Not Binary -The SPA web client is a **convenience UI**. The primary interface is IRC-style -client apps talking directly to the HTTP API. +All messages are JSON. No CBOR, no protobuf, no MessagePack, no custom binary +framing. + +**Rationale:** JSON is human-readable, universally supported, and debuggable +with `curl | jq`. Binary formats save bandwidth at the cost of debuggability +and ecosystem compatibility. Chat messages are small — the overhead of JSON +over binary is measured in bytes per message, not meaningful bandwidth. The +canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which +matters for signing. + +### Why Opaque Tokens Instead of JWTs + +JWTs encode claims that clients can decode and potentially rely on. This +creates a coupling between token format and client behavior. If the server +needs to revoke a token, change the expiry model, or add/remove claims, JWT +clients may break or behave incorrectly. + +Opaque tokens are simpler: +- Server generates 32 random bytes → hex-encodes → stores hash +- Client presents the token; server looks it up +- Revocation is a database delete +- No clock skew issues, no algorithm confusion, no "none" algorithm attacks +- Token format can change without breaking clients + +--- ## Architecture -### Transport: HTTP only +### Transport: HTTP Only All client↔server and server↔server communication uses HTTP/1.1+ with JSON request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. -- **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds - the connection for up to 15 seconds until messages arrive or timeout. - One endpoint for everything. -- **Client sending**: `POST /api/v1/messages` with a `to` field. That's it. -- **Server federation**: Servers exchange messages via HTTP to enable multi-server - networks (like IRC server linking) +- **Client reading**: Long-poll `GET /api/v1/messages` — server holds the + connection for up to 15s until messages arrive or timeout. One endpoint for + everything — channel messages, DMs, system events, numeric replies. +- **Client writing**: `POST /api/v1/messages` with a `command` field. One + endpoint for everything — PRIVMSG, JOIN, PART, NICK, TOPIC, etc. +- **Server federation**: Servers exchange messages via HTTP to enable + multi-server networks (like IRC server linking). -The entire read/write loop for a client is two endpoints. Everything else is -channel management and history. +The entire read/write loop for a client is two endpoints. Everything else +(state, history, channels, members, server info) is ancillary. -### Session Model +### Session Lifecycle ``` -┌─────────────────────────────────┐ -│ User Session (UUID) │ -│ nick: "alice" │ -│ signing key: ed25519:... │ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ Client A │ │ Client B │ ... │ -│ │ UUID │ │ UUID │ │ -│ │ token │ │ token │ │ -│ │ queue │ │ queue │ │ -│ └──────────┘ └──────────┘ │ -└─────────────────────────────────┘ +┌─ Client ──────────────────────────────────────────────────┐ +│ │ +│ 1. POST /api/v1/session {"nick":"alice"} │ +│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │ +│ │ +│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ +│ → {"status":"joined","channel":"#general"} │ +│ (Server fans out JOIN event to all #general members) │ +│ │ +│ 3. POST /api/v1/messages {"command":"PRIVMSG", │ +│ "to":"#general","body":["hello"]} │ +│ → {"id":"uuid-...","status":"sent"} │ +│ (Server fans out to all #general members' queues) │ +│ │ +│ 4. GET /api/v1/messages?after=0&timeout=15 │ +│ ← (held open up to 15s until messages arrive) │ +│ → {"messages":[...], "last_id": 42} │ +│ │ +│ 5. GET /api/v1/messages?after=42&timeout=15 │ +│ ← (recursive long-poll, using last_id as cursor) │ +│ │ +│ 6. POST /api/v1/messages {"command":"QUIT"} │ +│ → {"status":"quit"} │ +│ (Server broadcasts QUIT, removes from channels, │ +│ deletes session, releases nick) │ +│ │ +└────────────────────────────────────────────────────────────┘ ``` -- **User session**: server-assigned UUID. Represents a user on this server. - Has a nick (changeable, unique per server at any point in time). -- **Client**: each device/connection gets its own UUID and opaque auth token. - The token is the credential — present it to authenticate. -- **Queue**: each client has an independent S2C message queue. The server fans - out messages to all active client queues for the session. +### Queue Architecture -Sessions persist across disconnects. Messages queue until retrieved. Client -queues expire independently after a configurable idle timeout. +``` + ┌─────────────────┐ + │ messages table │ (one row per message, shared) + │ id | uuid | cmd│ + │ from | to | .. │ + └────────┬────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐ + │client_queue│ │client_queue│ │client_queue│ + │ user_id=1 │ │ user_id=2 │ │ user_id=3 │ + │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ + └────────────┘ └────────────┘ └────────────┘ + alice bob carol -### Message Protocol +Each message is stored ONCE. One queue entry per recipient. +``` -All messages use **IRC command names and numeric reply codes** from RFC 1459/2812. -The `command` field identifies the message type. +The `client_queues` table contains `(user_id, message_id)` pairs. When a +client polls with `GET /messages?after=`, the server queries for +queue entries with `id > after` for that user, joins against the messages +table, and returns the results. The `queue_id` (auto-incrementing primary +key of `client_queues`) serves as a monotonically increasing cursor. -#### Message Envelope +### In-Memory Broker -Every message is a JSON object with these fields: +The server maintains an in-memory notification broker to avoid database +polling. The broker is a map of `user_id → []chan struct{}`. When a message +is enqueued for a user: -| Field | Type | Required | Description | -|-----------|-----------------|----------|-------------| -| `command` | string | ✓ | IRC command name or 3-digit numeric code | -| `from` | string | | Sender nick or server name | -| `to` | string | | Destination: `#channel` or nick | -| `params` | array\ | | Additional IRC-style parameters | -| `body` | array \| object | | Structured body (never a raw string — see below) | -| `id` | string (uuid) | | Server-assigned message UUID | -| `ts` | string | | Server-assigned ISO 8601 timestamp | -| `meta` | object | | Extensible metadata (signatures, hashes, etc.) | +1. The handler calls `broker.Notify(userID)` +2. The broker closes all waiting channels for that user +3. Any goroutines blocked in `select` on those channels wake up +4. The woken handler queries the database for new queue entries +5. Messages are returned to the client -**Important:** Message bodies are **structured objects or arrays**, never raw -strings. This is a deliberate departure from IRC wire format that enables: +If the server restarts, the broker is empty — but this is fine because clients +that reconnect will poll immediately and get any queued messages from the +database. The broker is purely an optimization to avoid polling latency. -- **Multiline messages** — body is a list of lines, no escape sequences -- **Deterministic canonicalization** — for hashing and signing (see below) -- **Structured data** — commands like PUBKEY carry key material as objects +--- -For text messages, `body` is an array of strings (one per line): +## Protocol Specification + +### Message Envelope + +Every message — client-to-server, server-to-client, and server-to-server — uses +the same JSON envelope: ```json -{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} -{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["line one", "line two"]} +{ + "id": "string (uuid)", + "command": "string", + "from": "string", + "to": "string", + "params": ["string", ...], + "body": ["string", ...] | {...}, + "ts": "string (ISO 8601)", + "meta": {...} +} ``` -For numeric replies with text trailing parameters: +#### Field Reference + +| Field | Type | C2S | S2C | Description | +|-----------|---------------------|-----------|-----------|-------------| +| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | +| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.) or 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | +| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | +| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | +| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | +| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | +| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | +| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. | + +**Important invariants:** + +- `body` is **always** an array or object, **never** a raw string. This + enables deterministic canonicalization via RFC 8785 JCS. +- `from` is **always set by the server** on S2C messages. Clients may include + `from` on C2S messages, but it is ignored and overwritten. +- `id` and `ts` are **always set by the server**. Client-supplied values are + ignored. +- `meta` is **relayed verbatim**. The server stores it as-is and includes it + in S2C messages. It is never modified, validated, or interpreted by the + server. + +### Commands (C2S and S2C) + +All commands use the same envelope format regardless of direction. A `PRIVMSG` +from a client to the server has the same shape as the `PRIVMSG` relayed from +the server to other clients. The only differences are which fields the server +fills in (`id`, `ts`, `from`). + +#### PRIVMSG — Send Message + +Send a message to a channel or user. This is the primary messaging command. + +**C2S:** +```json +{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} +{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} +{"command": "PRIVMSG", "to": "bob", "body": ["hey, DM"]} +{"command": "PRIVMSG", "to": "#general", "body": ["signed message"], + "meta": {"sig": "base64...", "alg": "ed25519"}} +``` + +**S2C (as delivered to recipients):** +```json +{ + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} +} +``` + +**Behavior:** + +- If `to` starts with `#`, the message is sent to a channel. The server fans + out to all channel members (including the sender — the sender sees their own + message echoed back via the queue). +- If `to` is a bare nick, the message is a DM. The server fans out to the + recipient and the sender (so all of the sender's clients see the DM). +- `body` must be a non-empty array of strings. +- If the channel doesn't exist, the server returns HTTP 404. +- If the DM target nick doesn't exist, the server returns HTTP 404. + +**Response:** `201 Created` +```json +{"id": "uuid-string", "status": "sent"} +``` + +**IRC reference:** RFC 1459 §4.4.1 + +#### NOTICE — Send Notice + +Identical to PRIVMSG but **must not trigger auto-replies** from bots or +clients. This prevents infinite loops between automated systems. + +**C2S:** +```json +{"command": "NOTICE", "to": "#general", "body": ["server maintenance in 5 min"]} +``` + +**Behavior:** Same as PRIVMSG in all respects, except clients receiving a +NOTICE must not send an automatic reply. + +**IRC reference:** RFC 1459 §4.4.2 + +#### JOIN — Join Channel + +Join a channel. If the channel doesn't exist, it is created. + +**C2S:** +```json +{"command": "JOIN", "to": "#general"} +{"command": "JOIN", "to": "general"} +``` + +If the `#` prefix is omitted, the server adds it. + +**S2C (broadcast to all channel members, including the joiner):** +```json +{ + "id": "...", + "command": "JOIN", + "from": "alice", + "to": "#general", + "body": [], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} +} +``` + +**Behavior:** + +- If the channel doesn't exist, it is created with no topic and no modes. +- If the user is already in the channel, the JOIN is a no-op (no error, no + duplicate broadcast). +- The JOIN event is broadcast to **all** channel members, including the user + who joined. This lets the client confirm the join succeeded and lets other + members update their member lists. +- The first user to join a channel becomes its implicit operator (not yet + enforced in current implementation). + +**Response:** `200 OK` +```json +{"status": "joined", "channel": "#general"} +``` + +**IRC reference:** RFC 1459 §4.2.1 + +#### PART — Leave Channel + +Leave a channel. + +**C2S:** +```json +{"command": "PART", "to": "#general"} +{"command": "PART", "to": "#general", "body": ["goodbye"]} +``` + +**S2C (broadcast to all channel members, including the leaver):** +```json +{ + "id": "...", + "command": "PART", + "from": "alice", + "to": "#general", + "body": ["goodbye"], + "ts": "...", + "meta": {} +} +``` + +**Behavior:** + +- The PART event is broadcast **before** the member is removed, so the + departing user receives their own PART event. +- If the channel is empty after the user leaves, the channel is **deleted** + (ephemeral channels). +- If the user is not in the channel, the server returns an error. +- The `body` field is optional and contains a part message (reason). + +**Response:** `200 OK` +```json +{"status": "parted", "channel": "#general"} +``` + +**IRC reference:** RFC 1459 §4.2.2 + +#### NICK — Change Nickname + +Change the user's nickname. + +**C2S:** +```json +{"command": "NICK", "body": ["newnick"]} +``` + +**S2C (broadcast to all users sharing a channel with the changer):** +```json +{ + "id": "...", + "command": "NICK", + "from": "oldnick", + "to": "", + "body": ["newnick"], + "ts": "...", + "meta": {} +} +``` + +**Behavior:** + +- `body[0]` is the new nick. Must be 1–32 characters. +- The `from` field in the broadcast contains the **old** nick. +- The `body[0]` in the broadcast contains the **new** nick. +- The NICK event is broadcast to the user themselves and to all users who + share at least one channel with the changer. Each recipient receives the + event exactly once, even if they share multiple channels. +- If the new nick is already taken, the server returns HTTP 409 Conflict. + +**Response:** `200 OK` +```json +{"status": "ok", "nick": "newnick"} +``` + +**Error (nick taken):** `409 Conflict` +```json +{"error": "nick already in use"} +``` + +**IRC reference:** RFC 1459 §4.1.2 + +#### TOPIC — Set Channel Topic + +Set or change a channel's topic. + +**C2S:** +```json +{"command": "TOPIC", "to": "#general", "body": ["Welcome to #general"]} +``` + +**S2C (broadcast to all channel members):** +```json +{ + "id": "...", + "command": "TOPIC", + "from": "alice", + "to": "#general", + "body": ["Welcome to #general"], + "ts": "...", + "meta": {} +} +``` + +**Behavior:** + +- Updates the channel's topic in the database. +- The TOPIC event is broadcast to all channel members. +- If the channel doesn't exist, the server returns an error. +- If the channel has mode `+t` (topic lock), only operators can change the + topic (not yet enforced). + +**Response:** `200 OK` +```json +{"status": "ok", "topic": "Welcome to #general"} +``` + +**IRC reference:** RFC 1459 §4.2.4 + +#### QUIT — Disconnect + +Destroy the session and disconnect from the server. + +**C2S:** +```json +{"command": "QUIT"} +{"command": "QUIT", "body": ["leaving"]} +``` + +**S2C (broadcast to all users sharing channels with the quitter):** +```json +{ + "id": "...", + "command": "QUIT", + "from": "alice", + "to": "", + "body": ["leaving"], + "ts": "...", + "meta": {} +} +``` + +**Behavior:** + +- The QUIT event is broadcast to all users who share a channel with the + quitting user. The quitting user does **not** receive their own QUIT. +- The user is removed from all channels. +- Empty channels are deleted (ephemeral). +- The user's session is destroyed — the auth token is invalidated, the nick + is released. +- Subsequent requests with the old token return HTTP 401. + +**Response:** `200 OK` +```json +{"status": "quit"} +``` + +**IRC reference:** RFC 1459 §4.1.6 + +#### PING — Keepalive + +Client keepalive. Server responds synchronously with PONG. + +**C2S:** +```json +{"command": "PING"} +``` + +**Response (synchronous, not via the queue):** `200 OK` +```json +{"command": "PONG", "from": "servername"} +``` + +**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not +a queued message. This is deliberate: keepalives should be low-latency and +not pollute the message queue. + +**IRC reference:** RFC 1459 §4.6.2, §4.6.3 + +#### MODE — Set/Query Modes (Planned) + +Set channel or user modes. + +**C2S:** +```json +{"command": "MODE", "to": "#general", "params": ["+m"]} +{"command": "MODE", "to": "#general", "params": ["+o", "alice"]} +``` + +**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the +planned mode set. + +**IRC reference:** RFC 1459 §4.2.3 + +#### KICK — Kick User (Planned) + +Remove a user from a channel. + +**C2S:** +```json +{"command": "KICK", "to": "#general", "params": ["bob"], "body": ["misbehaving"]} +``` + +**Status:** Not yet implemented. + +**IRC reference:** RFC 1459 §4.2.8 + +#### PUBKEY — Announce Signing Key + +Distribute a public signing key to channel members. + +**C2S:** +```json +{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +``` + +**S2C (relayed to channel members):** +```json +{ + "id": "...", + "command": "PUBKEY", + "from": "alice", + "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}, + "ts": "...", + "meta": {} +} +``` + +**Behavior:** The server relays PUBKEY messages verbatim. It does not verify, +store, or interpret the key material. See [Security Model](#security-model) +for the full key distribution protocol. + +**Status:** Not yet implemented. + +### Numeric Reply Codes (S2C Only) + +Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from +the server to the client (never C2S) and use 3-digit string codes in the +`command` field. + +| Code | Name | When Sent | Example | +|------|----------------------|-----------|---------| +| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | +| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is chatserver, running version 0.1"]}` | +| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | +| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["chatserver","0.1","","imnst"]}` | +| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` | +| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | +| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` | +| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | +| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | +| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- chatserver Message of the Day -"]}` | +| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | +| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | +| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | +| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | +| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | +| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | + +**Note:** Numeric replies are planned for full implementation. The current MVP +returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead +of numeric replies for error conditions. Numeric replies in the message queue +will be added post-MVP. + +### Channel Modes + +Inspired by IRC, simplified: + +| Mode | Name | Meaning | +|------|--------------|---------| +| `+i` | Invite-only | Only invited users can join | +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | +| `+s` | Secret | Channel hidden from LIST response | +| `+t` | Topic lock | Only operators can change the topic | +| `+n` | No external | Only channel members can send messages to the channel | + +**User channel modes (set per-user per-channel):** + +| Mode | Meaning | Display prefix | +|------|---------|----------------| +| `+o` | Operator | `@` in NAMES reply | +| `+v` | Voice | `+` in NAMES reply | + +**Status:** Channel modes are defined but not yet enforced. The `modes` column +exists in the channels table but the server does not check modes on actions. + +--- + +## API Reference + +All endpoints accept and return `application/json`. Authenticated endpoints +require `Authorization: Bearer ` header. The token is obtained from +`POST /api/v1/session`. + +All API responses include appropriate HTTP status codes. Error responses have +the format: ```json -{"command": "001", "to": "nick", "body": ["Welcome to the network, nick"]} -{"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["@op1 alice bob"]} +{"error": "human-readable error message"} ``` -For structured data (keys, etc.), `body` is an object: +### POST /api/v1/session — Create Session + +Create a new user session. This is the entry point for all clients. + +**Request:** +```json +{"nick": "alice"} +``` + +| Field | Type | Required | Constraints | +|--------|--------|----------|-------------| +| `nick` | string | Yes | 1–32 characters, must be unique on the server | + +**Response:** `201 Created` +```json +{ + "id": 1, + "nick": "alice", + "token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3" +} +``` + +| Field | Type | Description | +|---------|---------|-------------| +| `id` | integer | Server-assigned user ID | +| `nick` | string | Confirmed nick (always matches request on success) | +| `token` | string | 64-character hex auth token. Store this — it's the only credential. | + +**Errors:** + +| Status | Error | When | +|--------|-------|------| +| 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 409 | `nick already taken` | Another active session holds this nick | + +**curl example:** +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ + -H 'Content-Type: application/json' \ + -d '{"nick":"alice"}' | jq -r .token) +echo $TOKEN +``` + +### GET /api/v1/state — Get Session State + +Return the current user's session state. + +**Request:** No body. Requires auth. + +**Response:** `200 OK` +```json +{ + "id": 1, + "nick": "alice", + "channels": [ + {"id": 1, "name": "#general", "topic": "Welcome!"}, + {"id": 2, "name": "#dev", "topic": ""} + ] +} +``` + +| Field | Type | Description | +|------------|--------|-------------| +| `id` | integer | User ID | +| `nick` | string | Current nick | +| `channels` | array | Channels the user is a member of | + +Each channel object: + +| Field | Type | Description | +|---------|---------|-------------| +| `id` | integer | Channel ID | +| `name` | string | Channel name (e.g., `#general`) | +| `topic` | string | Channel topic (empty string if unset) | + +**curl example:** +```bash +curl -s http://localhost:8080/api/v1/state \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +### GET /api/v1/messages — Poll Messages (Long-Poll) + +Retrieve messages from the client's delivery queue. This is the primary +real-time endpoint — clients call it in a loop. + +**Query Parameters:** + +| Param | Type | Default | Description | +|-----------|---------|---------|-------------| +| `after` | integer | `0` | Return only queue entries with ID > this value. Use `last_id` from the previous response. | +| `timeout` | integer | `0` | Long-poll timeout in seconds. `0` = return immediately. Max `30`. Recommended: `15`. | + +**Response:** `200 OK` +```json +{ + "messages": [ + { + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "JOIN", + "from": "bob", + "to": "#general", + "body": [], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} + }, + { + "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:01.000000000Z", + "meta": {} + } + ], + "last_id": 42 +} +``` + +| Field | Type | Description | +|------------|---------|-------------| +| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)). Empty array if no messages. | +| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | + +**Long-poll behavior:** + +1. If messages are immediately available (queue entries with ID > `after`), + the server responds instantly. +2. If no messages are available and `timeout` > 0, the server holds the + connection open. +3. The server responds when: + - A message arrives for this user (instantly via in-memory broker) + - The timeout expires (returns `{"messages":[], "last_id": }`) + - The client disconnects (no response) + +**curl example (immediate):** +```bash +curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +**curl example (long-poll, 15s):** +```bash +curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +### POST /api/v1/messages — Send Command + +Send any client-to-server command. The `command` field determines the action. +This is the unified write endpoint — there are no separate endpoints for join, +part, nick, etc. + +**Request body:** An IRC message envelope with `command` and relevant fields: ```json -{"command": "PUBKEY", "from": "nick", "body": {"alg": "ed25519", "key": "base64..."}} +{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} ``` -#### IRC Command Mapping +See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command +reference with all required and optional fields. -**Commands (C2S and S2C):** +**Command dispatch table:** -| Command | RFC | Description | -|-----------|--------------|--------------------------------------| -| `PRIVMSG` | 1459 §4.4.1 | Message to channel or user | -| `NOTICE` | 1459 §4.4.2 | Notice (must not trigger auto-reply) | -| `JOIN` | 1459 §4.2.1 | Join a channel | -| `PART` | 1459 §4.2.2 | Leave a channel | -| `QUIT` | 1459 §4.1.6 | Disconnect from server | -| `NICK` | 1459 §4.1.2 | Change nickname | -| `MODE` | 1459 §4.2.3 | Set/query channel or user modes | -| `TOPIC` | 1459 §4.2.4 | Set/query channel topic | -| `KICK` | 1459 §4.2.8 | Kick user from channel | -| `PING` | 1459 §4.6.2 | Keepalive | -| `PONG` | 1459 §4.6.3 | Keepalive response | -| `PUBKEY` | (extension) | Announce/relay signing public key | +| Command | Required Fields | Optional | Response Status | +|-----------|---------------------|---------------|-----------------| +| `PRIVMSG` | `to`, `body` | `meta` | 201 Created | +| `NOTICE` | `to`, `body` | `meta` | 201 Created | +| `JOIN` | `to` | | 200 OK | +| `PART` | `to` | `body` | 200 OK | +| `NICK` | `body` | | 200 OK | +| `TOPIC` | `to`, `body` | | 200 OK | +| `QUIT` | | `body` | 200 OK | +| `PING` | | | 200 OK | -All C2S commands may be relayed S2C to other users (e.g. JOIN, PART, PRIVMSG). +**Errors (all commands):** -**Numeric Reply Codes (S2C):** +| Status | Error | When | +|--------|-------|------| +| 400 | `invalid request` | Malformed JSON | +| 400 | `to field required` | Missing `to` for commands that need it | +| 400 | `body required` | Missing `body` for commands that need it | +| 400 | `unknown command: X` | Unrecognized command | +| 401 | `unauthorized` | Missing or invalid auth token | +| 404 | `channel not found` | Target channel doesn't exist | +| 404 | `user not found` | DM target nick doesn't exist | +| 409 | `nick already in use` | NICK target is taken | -| Code | Name | Description | -|------|----------------------|-------------| -| 001 | RPL_WELCOME | Welcome after session creation | -| 002 | RPL_YOURHOST | Server host information | -| 003 | RPL_CREATED | Server creation date | -| 004 | RPL_MYINFO | Server info and modes | -| 322 | RPL_LIST | Channel list entry | -| 323 | RPL_LISTEND | End of channel list | -| 332 | RPL_TOPIC | Channel topic | -| 353 | RPL_NAMREPLY | Channel member list | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 372 | RPL_MOTD | MOTD line | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 401 | ERR_NOSUCHNICK | No such nick/channel | -| 403 | ERR_NOSUCHCHANNEL | No such channel | -| 433 | ERR_NICKNAMEINUSE | Nickname already in use | -| 442 | ERR_NOTONCHANNEL | Not on that channel | -| 482 | ERR_CHANOPRIVSNEEDED | Not channel operator | +### GET /api/v1/history — Message History -**Server-to-Server (Federation):** +Fetch historical messages for a channel. Returns messages in chronological +order (oldest first). -Federated servers use the same IRC commands. After link establishment, servers -exchange a burst of JOIN, NICK, TOPIC, and MODE commands to sync state. -PING/PONG serve as inter-server keepalives. +**Query Parameters:** -#### Message Examples +| Param | Type | Default | Description | +|----------|---------|---------|-------------| +| `target` | string | (required) | Channel name (e.g., `#general`) | +| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | +| `limit` | integer | `50` | Maximum messages to return. | +**Response:** `200 OK` ```json -{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} - -{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"], "meta": {"sig": "base64...", "alg": "ed25519"}} - -{"command": "PRIVMSG", "from": "alice", "to": "bob", "body": ["hey, DM"]} - -{"command": "JOIN", "from": "bob", "to": "#general"} - -{"command": "PART", "from": "bob", "to": "#general", "body": ["later"]} - -{"command": "NICK", "from": "oldnick", "body": ["newnick"]} - -{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]} - -{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["@op1 alice bob +voiced1"]} - -{"command": "433", "to": "*", "params": ["alice"], "body": ["Nickname is already in use"]} - -{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64..."}} +[ + { + "id": "uuid-1", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["first message"], + "ts": "2026-02-10T19:00:00.000000000Z", + "meta": {} + }, + { + "id": "uuid-2", + "command": "PRIVMSG", + "from": "bob", + "to": "#general", + "body": ["second message"], + "ts": "2026-02-10T19:01:00.000000000Z", + "meta": {} + } +] ``` -#### JSON Schemas +**Note:** History currently returns only PRIVMSG messages (not JOIN/PART/etc. +events). Event messages are delivered via the live queue only. -Full JSON Schema (draft 2020-12) definitions for all message types are in -[`schema/`](schema/). See [`schema/README.md`](schema/README.md) for the -complete index. +**curl example:** +```bash +# Latest 50 messages in #general +curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ + -H "Authorization: Bearer $TOKEN" | jq . -### Canonicalization and Signing +# Older messages (pagination) +curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +### GET /api/v1/channels — List Channels + +List all channels on the server. + +**Response:** `200 OK` +```json +[ + {"id": 1, "name": "#general", "topic": "Welcome!"}, + {"id": 2, "name": "#dev", "topic": "Development discussion"} +] +``` + +### GET /api/v1/channels/{name}/members — Channel Members + +List members of a channel. The `{name}` parameter is the channel name +**without** the `#` prefix (it's added by the server). + +**Response:** `200 OK` +```json +[ + {"id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z"}, + {"id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z"} +] +``` + +**curl example:** +```bash +curl -s http://localhost:8080/api/v1/channels/general/members \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +### GET /api/v1/server — Server Info + +Return server metadata. No authentication required. + +**Response:** `200 OK` +```json +{ + "name": "My Chat Server", + "motd": "Welcome! Be nice." +} +``` + +### GET /.well-known/healthcheck.json — Health Check + +Standard health check endpoint. No authentication required. + +**Response:** `200 OK` +```json +{"status": "ok"} +``` + +--- + +## Message Flow + +### Channel Message Flow + +``` +Alice Server Bob + │ │ │ + │ POST /messages │ │ + │ {PRIVMSG, #gen, "hi"} │ │ + │───────────────────────>│ │ + │ │ 1. Store in messages │ + │ │ 2. Query #gen members │ + │ │ → [alice, bob] │ + │ │ 3. Enqueue for alice │ + │ │ 4. Enqueue for bob │ + │ │ 5. Notify alice broker │ + │ │ 6. Notify bob broker │ + │ 201 {"status":"sent"} │ │ + │<───────────────────────│ │ + │ │ │ + │ GET /messages?after=N │ GET /messages?after=M │ + │ (long-poll wakes up) │ (long-poll wakes up) │ + │───────────────────────>│<───────────────────────│ + │ │ │ + │ {messages: [{PRIVMSG, │ {messages: [{PRIVMSG, │ + │ from:alice, "hi"}]} │ from:alice, "hi"}]} │ + │<───────────────────────│───────────────────────>│ +``` + +### DM Flow + +``` +Alice Server Bob + │ │ │ + │ POST /messages │ │ + │ {PRIVMSG, "bob", "yo"} │ │ + │───────────────────────>│ │ + │ │ 1. Resolve nick "bob" │ + │ │ 2. Store in messages │ + │ │ 3. Enqueue for bob │ + │ │ 4. Enqueue for alice │ + │ │ (echo to sender) │ + │ │ 5. Notify both │ + │ 201 {"status":"sent"} │ │ + │<───────────────────────│ │ + │ │ │ + │ (alice sees her own DM │ (bob sees DM from │ + │ on all her clients) │ alice) │ +``` + +### JOIN Flow + +``` +Alice Server Bob (already in #gen) + │ │ │ + │ POST /messages │ │ + │ {JOIN, "#general"} │ │ + │───────────────────────>│ │ + │ │ 1. Get/create #general │ + │ │ 2. Add alice to members│ + │ │ 3. Store JOIN message │ + │ │ 4. Fan out to all │ + │ │ members (alice, bob) │ + │ 200 {"joined"} │ │ + │<───────────────────────│ │ + │ │ │ + │ (alice's queue gets │ (bob's queue gets │ + │ JOIN from alice) │ JOIN from alice) │ +``` + +--- + +## Canonicalization and Signing Messages support optional cryptographic signatures for integrity verification. Servers relay signatures verbatim without verifying them — verification is purely a client-side concern. -#### Canonicalization (RFC 8785 JCS) +### Canonicalization (RFC 8785 JCS) To produce a deterministic byte representation of a message for signing: -1. Remove `meta.sig` from the message (the signature itself is not signed) -2. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): - - Object keys sorted lexicographically - - No whitespace - - Numbers in shortest form - - UTF-8 encoding -3. The resulting byte string is the signing input +1. Start with the full message envelope (including `id`, `ts`, `from`, etc.) +2. Remove `meta.sig` from the message (the signature itself is not signed) +3. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): + - Object keys sorted lexicographically (Unicode code point order) + - No insignificant whitespace + - Numbers serialized in shortest form (no trailing zeros) + - Strings escaped per JSON spec (no unnecessary escapes) + - UTF-8 encoding throughout +4. The resulting byte string is the signing input + +**Example:** + +Given this message: +```json +{ + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello"], + "id": "abc-123", + "ts": "2026-02-10T20:00:00Z", + "meta": {"alg": "ed25519"} +} +``` + +The JCS canonical form is: +``` +{"body":["hello"],"command":"PRIVMSG","from":"alice","id":"abc-123","meta":{"alg":"ed25519"},"to":"#general","ts":"2026-02-10T20:00:00Z"} +``` This is why `body` must be an object or array — raw strings would be ambiguous -under canonicalization. +under canonicalization (a bare string `hello` is not valid JSON, and +`"hello"` has different canonical forms depending on escaping rules). -#### Signing Flow +### Signing Flow -1. Client generates an Ed25519 keypair -2. Client announces public key: `{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}` -3. Server relays PUBKEY to channel members / stores for the session +1. Client generates an Ed25519 keypair (32-byte seed → 64-byte secret key, + 32-byte public key) +2. Client announces public key via PUBKEY command: + ```json + {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64url-encoded-pubkey"}} + ``` +3. Server relays PUBKEY to channel members and/or stores for the session 4. When sending a message, client: - a. Constructs the message without `meta.sig` - b. Canonicalizes per JCS - c. Signs with private key - d. Adds `meta.sig` (base64) and `meta.alg` -5. Recipients verify by repeating steps a–c and checking the signature - against the sender's announced public key + a. Constructs the complete message envelope **without** `meta.sig` + b. Canonicalizes per JCS (step above) + c. Signs the canonical bytes with the Ed25519 private key + d. Adds `meta.sig` (base64url-encoded signature) and `meta.alg` ("ed25519") +5. Server stores and relays the message including `meta` verbatim +6. Recipients verify by: + a. Extracting and removing `meta.sig` from the received message + b. Canonicalizing the remaining message per JCS + c. Verifying the Ed25519 signature against the sender's announced public key -#### PUBKEY Message +### PUBKEY Distribution ```json -{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +{"command": "PUBKEY", "from": "alice", + "body": {"alg": "ed25519", "key": "base64url-encoded-32-byte-pubkey"}} ``` -Servers relay PUBKEY messages to all channel members. Clients cache public keys -and use them to verify `meta.sig` on incoming messages. Key distribution is -trust-on-first-use (TOFU). There is no key revocation mechanism. +- Servers relay PUBKEY messages to all channel members +- Clients cache public keys locally, indexed by (server, nick) +- Key distribution uses **TOFU** (trust on first use): the first key seen for + a nick is trusted; subsequent different keys trigger a warning +- **There is no key revocation mechanism** — if a key is compromised, the user + must change their nick or wait for the old key's TOFU cache to expire -### API Endpoints +### Signed Message Example -All endpoints accept and return `application/json`. Authenticated endpoints -require `Authorization: Bearer ` header. +```json +{ + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["this message is signed"], + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": { + "alg": "ed25519", + "sig": "base64url-encoded-64-byte-signature" + } +} +``` -The API is the primary interface — designed for IRC-style clients. The entire -client loop is: +--- -1. `POST /api/v1/session` — create a session, get a token -2. `GET /api/v1/state` — see who you are and what channels you're in -3. `GET /api/v1/messages?timeout=15` — long-poll for all messages (channel, DM, system) -4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"` +## Security Model -That's the core. Everything else (join, part, history, members) is ancillary. +### Threat Model -#### Quick example (curl) +The server is **trusted for metadata** (it knows who sent what, when, to whom) +but **untrusted for message integrity** (signatures let clients verify that +messages haven't been tampered with). This is the same trust model as email +with PGP/DKIM — the mail server sees everything, but signatures prove +authenticity. + +### Authentication + +- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy). + Tokens are stored in the database and validated on every request. +- **No passwords**: Session creation requires only a nick. The token is the + sole credential. +- **Token security**: Tokens should be treated like session cookies. Transmit + only over HTTPS in production. If a token is compromised, the attacker has + full access to the session until QUIT or expiry. + +### Message Integrity + +- **Optional signing**: Clients may sign messages using Ed25519. The server + relays signatures verbatim in the `meta` field. +- **Server does not verify signatures**: Verification is purely client-side. + This means the server cannot selectively reject forged messages, but it also + means the server cannot be compelled to enforce a signing policy. +- **Canonicalization**: Messages are canonicalized via RFC 8785 JCS before + signing, ensuring deterministic byte representation regardless of JSON + serialization differences between implementations. + +### Key Management + +- **TOFU (Trust On First Use)**: Clients trust the first public key they see + for a nick. This is the same model as SSH host keys. It's simple and works + well when users don't change keys frequently. +- **No key revocation**: Deliberate omission. Key revocation systems are + complex (CRLs, OCSP, key servers) and rarely work well in practice. If your + key is compromised, change your nick. +- **No CA / PKI**: There is no certificate authority. Identity is a key, not + a name bound to a key by a third party. + +### DM Privacy + +- **DMs are not end-to-end encrypted** in the current implementation. The + server can read DM content. E2E encryption for DMs is planned (see + [Roadmap](#roadmap)). +- **DMs are stored** in the messages table, subject to the same rotation + policy as channel messages. + +### Transport Security + +- **HTTPS is strongly recommended** for production deployments. The server + itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS + termination. +- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`). + Restrict this in production via reverse proxy configuration if needed. + +--- + +## Federation (Server-to-Server) + +Federation allows multiple chat servers to link together, forming a network +where users on different servers can share channels — similar to IRC server +linking. + +**Status:** Not yet implemented. This section documents the design. + +### Link Establishment + +Server links are **manually configured** by operators. There is no +autodiscovery, no mesh networking, no DNS-based lookup. Operators on both +servers must agree to link and configure shared authentication credentials. + +``` +POST /api/v1/federation/link +{ + "server_name": "peer.example.com", + "shared_key": "pre-shared-secret" +} +``` + +Both servers must configure the link. Authentication uses a pre-shared key +(hashed, never transmitted in plain text after initial setup). + +### Message Relay + +Once linked, servers relay messages using the same IRC envelope format: + +``` +POST /api/v1/federation/relay +{ + "command": "PRIVMSG", + "from": "alice@server1.example.com", + "to": "#shared-channel", + "body": ["hello from server1"], + "meta": {"sig": "base64...", "alg": "ed25519"} +} +``` + +Key properties: + +- **Signatures are relayed verbatim** — federated servers do not strip, + modify, or re-sign messages. A signature from a user on server1 can be + verified by a user on server2. +- **Nick namespacing**: In federated mode, nicks include a server suffix + (`nick@server`) to prevent collisions. Within a single server, bare nicks + are used. + +### State Synchronization + +After link establishment, servers exchange a **burst** of state: + +1. `NICK` commands for all connected users +2. `JOIN` commands for all shared channel memberships +3. `TOPIC` commands for all channel topics +4. `MODE` commands for all channel modes + +This mirrors IRC's server burst protocol. + +### S2S Commands + +| Command | Description | +|----------|-------------| +| `RELAY` | Relay a message from a remote user | +| `LINK` | Establish server link | +| `UNLINK` | Tear down server link | +| `SYNC` | Request full state synchronization | +| `PING` | Inter-server keepalive | +| `PONG` | Inter-server keepalive response | + +### Federation Endpoints + +``` +POST /api/v1/federation/link — Establish server link +POST /api/v1/federation/relay — Relay messages between linked servers +GET /api/v1/federation/status — Link status and peer list +POST /api/v1/federation/unlink — Tear down a server link +``` + +--- + +## Storage + +### Database + +SQLite by default (single-file, zero-config). The server uses +[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure-Go SQLite +implementation — no CGO required, cross-compiles cleanly. + +Postgres support is planned for larger deployments but not yet implemented. + +### Schema + +The database schema is managed via embedded SQL migration files in +`internal/db/schema/`. Migrations run automatically on server start. + +**Current tables:** + +#### `users` +| Column | Type | Description | +|-------------|----------|-------------| +| `id` | INTEGER | Primary key (auto-increment) | +| `nick` | TEXT | Unique nick | +| `token` | TEXT | Unique auth token (64 hex chars) | +| `created_at`| DATETIME | Session creation time | +| `last_seen` | DATETIME | Last API request time | + +#### `channels` +| Column | Type | Description | +|-------------|----------|-------------| +| `id` | INTEGER | Primary key (auto-increment) | +| `name` | TEXT | Unique channel name (e.g., `#general`) | +| `topic` | TEXT | Channel topic (default empty) | +| `created_at`| DATETIME | Channel creation time | +| `updated_at`| DATETIME | Last modification time | + +#### `channel_members` +| Column | Type | Description | +|-------------|----------|-------------| +| `id` | INTEGER | Primary key (auto-increment) | +| `channel_id`| INTEGER | FK → channels.id | +| `user_id` | INTEGER | FK → users.id | +| `joined_at` | DATETIME | When the user joined | + +Unique constraint on `(channel_id, user_id)`. + +#### `messages` +| Column | Type | Description | +|-------------|----------|-------------| +| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | +| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | +| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | +| `msg_from` | TEXT | Sender nick | +| `msg_to` | TEXT | Target (`#channel` or nick) | +| `body` | TEXT | JSON-encoded body (array or object) | +| `meta` | TEXT | JSON-encoded metadata | +| `created_at`| DATETIME | Server timestamp | + +Indexes on `(msg_to, id)` and `(created_at)`. + +#### `client_queues` +| Column | Type | Description | +|-------------|----------|-------------| +| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | +| `user_id` | INTEGER | FK → users.id | +| `message_id`| INTEGER | FK → messages.id | +| `created_at`| DATETIME | When the entry was queued | + +Unique constraint on `(user_id, message_id)`. Index on `(user_id, id)`. + +The `client_queues.id` is the monotonically increasing cursor used by +`GET /messages?after=`. This is more reliable than timestamps (no clock +skew issues) and simpler than UUIDs (integer comparison vs. string comparison). + +### Data Lifecycle + +- **Messages**: Stored indefinitely in the current implementation. Rotation + per `MAX_HISTORY` is planned. +- **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. + +--- + +## Configuration + +All configuration is via environment variables, read by +[Viper](https://github.com/spf13/viper). A `.env` file in the working +directory is also loaded automatically via +[godotenv](https://github.com/joho/godotenv). + +| Variable | Type | Default | Description | +|--------------------|---------|--------------------------------------|-------------| +| `PORT` | int | `8080` | HTTP listen port | +| `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. | +| `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) | +| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | +| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if empty. | +| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | +| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | +| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | +| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | +| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | + +### Example `.env` file ```bash -# Create a session (get session UUID, client UUID, and auth token) -TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ - -d '{"nick":"alice"}' | jq -r .token) +PORT=8080 +SERVER_NAME=My Chat Server +MOTD=Welcome! Be excellent to each other. +DEBUG=false +DBURL=file:./data.db?_journal_mode=WAL +SESSION_TIMEOUT=86400 +``` -# Join a channel (creates it if it doesn't exist) +--- + +## Deployment + +### Docker (Recommended) + +The Docker image contains a single static binary (`chatd`) and nothing else. + +```bash +# Build +docker build -t chat . + +# Run +docker run -p 8080:8080 \ + -v chat-data:/data \ + -e DBURL="file:/data/chat.db?_journal_mode=WAL" \ + -e SERVER_NAME="My Server" \ + -e MOTD="Welcome!" \ + chat +``` + +The Dockerfile is a multi-stage build: +1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify + compilation, not included in final image) +2. **Final stage**: Alpine Linux + `chatd` binary only + +```dockerfile +FROM golang:1.24-alpine AS builder +WORKDIR /src +RUN apk add --no-cache make +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /chatd ./cmd/chatd/ +RUN go build -o /chat-cli ./cmd/chat-cli/ + +FROM alpine:latest +COPY --from=builder /chatd /usr/local/bin/chatd +EXPOSE 8080 +CMD ["chatd"] +``` + +### Binary + +```bash +# Build from source +make build +# Binary at ./bin/chatd + +# Run +./bin/chatd +# Listens on :8080, creates ./data.db +``` + +### Reverse Proxy (Production) + +For production, run behind a TLS-terminating reverse proxy. + +**Caddy:** +``` +chat.example.com { + reverse_proxy localhost:8080 +} +``` + +**nginx:** +```nginx +server { + listen 443 ssl; + server_name chat.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; # Must be > long-poll timeout + } +} +``` + +**Important:** Set `proxy_read_timeout` (nginx) or equivalent to at least 60 +seconds to accommodate long-poll connections. + +### SQLite Considerations + +- **WAL mode** is enabled by default (`?_journal_mode=WAL` in the connection + string). This allows concurrent reads during writes. +- **Single writer**: SQLite allows only one writer at a time. For high-traffic + servers, Postgres support is planned. +- **Backup**: The database is a single file. Back it up with `sqlite3 data.db ".backup backup.db"` or just copy the file (safe with WAL mode). +- **Location**: By default, `data.db` is created in the working directory. + Use the `DBURL` env var to place it elsewhere. + +--- + +## Client Development Guide + +This section explains how to write a client against the chat API. The API is +designed to be simple enough that a basic client can be written in any language +with an HTTP client library. + +### Minimal Client Loop + +A complete client needs only four HTTP calls: + +``` +1. POST /api/v1/session → get token +2. POST /api/v1/messages (JOIN) → join channels +3. GET /api/v1/messages (loop) → receive messages +4. POST /api/v1/messages → send messages +``` + +### Step-by-Step with curl + +```bash +# 1. Create a session +export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ + -H 'Content-Type: application/json' \ + -d '{"nick":"testuser"}' | jq -r .token) + +# 2. Join a channel curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ -d '{"command":"JOIN","to":"#general"}' -# Send a message +# 3. Send a message curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ - -d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}' + -H 'Content-Type: application/json' \ + -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}' -# Poll for messages (long-poll, 15s timeout) -curl -s "http://localhost:8080/api/v1/messages?timeout=15" \ - -H "Authorization: Bearer $TOKEN" +# 4. Poll for messages (one-shot) +curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \ + -H "Authorization: Bearer $TOKEN" | jq . + +# 5. Long-poll (blocks up to 15s waiting for messages) +curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \ + -H "Authorization: Bearer $TOKEN" | jq . + +# 6. Send a DM +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}' + +# 7. Change nick +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"command":"NICK","body":["newnick"]}' + +# 8. Set channel topic +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"command":"TOPIC","to":"#general","body":["New topic!"]}' + +# 9. Leave a channel +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"command":"PART","to":"#general","body":["goodbye"]}' + +# 10. Disconnect +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"command":"QUIT","body":["leaving"]}' ``` -#### Session +### Implementing Long-Poll in Code -``` -POST /api/v1/session — Create session { "nick": "..." } - → { id, nick, token } - Token is opaque (random), not JWT. - Token implicitly identifies the client. +The key to real-time messaging is the poll loop. Here's the pattern: + +```python +# Python example +import requests, json + +BASE = "http://localhost:8080/api/v1" +token = None +last_id = 0 + +# Create session +resp = requests.post(f"{BASE}/session", json={"nick": "pybot"}) +token = resp.json()["token"] +headers = {"Authorization": f"Bearer {token}"} + +# Join channel +requests.post(f"{BASE}/messages", headers=headers, + json={"command": "JOIN", "to": "#general"}) + +# Poll loop +while True: + try: + resp = requests.get(f"{BASE}/messages", + headers=headers, + params={"after": last_id, "timeout": 15}, + timeout=20) # HTTP timeout > long-poll timeout + data = resp.json() + if data.get("last_id"): + last_id = data["last_id"] + for msg in data.get("messages", []): + print(f"[{msg['command']}] <{msg.get('from','')}> " + f"{' '.join(msg.get('body', []))}") + except requests.exceptions.Timeout: + continue # Normal — just re-poll + except Exception as e: + print(f"Error: {e}") + time.sleep(2) # Back off on errors ``` -#### State - -``` -GET /api/v1/state — User state: nick, session_id, client_id, - and list of joined channels +```javascript +// JavaScript/browser example +async function pollLoop(token) { + let lastId = 0; + while (true) { + try { + const resp = await fetch( + `/api/v1/messages?after=${lastId}&timeout=15`, + {headers: {'Authorization': `Bearer ${token}`}} + ); + if (resp.status === 401) { /* session expired */ break; } + const data = await resp.json(); + if (data.last_id) lastId = data.last_id; + for (const msg of data.messages || []) { + handleMessage(msg); + } + } catch (e) { + await new Promise(r => setTimeout(r, 2000)); // back off + } + } +} ``` -#### Messages (unified stream) +### Handling Message Types + +Clients should handle these message commands from the queue: + +| Command | Display As | +|-----------|------------| +| `PRIVMSG` | ` message text` | +| `NOTICE` | `-nick- message text` (do not auto-reply) | +| `JOIN` | `*** nick has joined #channel` | +| `PART` | `*** nick has left #channel (reason)` | +| `QUIT` | `*** nick has quit (reason)` | +| `NICK` | `*** oldnick is now known as newnick` | +| `TOPIC` | `*** nick set topic: new topic` | +| Numerics | Display body text (e.g., welcome messages, error messages) | + +### Error Handling + +- **HTTP 401**: Token expired or invalid. Re-create session. +- **HTTP 404**: Channel or user not found. +- **HTTP 409**: Nick already taken (on session creation or NICK change). +- **HTTP 400**: Malformed request. Check the `error` field in the response. +- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). + +### Tips for Client Authors + +1. **Set HTTP timeout > long-poll timeout**: If your long-poll timeout is 15s, + set your HTTP client timeout to at least 20s to avoid cutting off valid + responses. +2. **Always use `after` parameter**: Start with `after=0`, then use `last_id` + from each response. Never reset to 0 unless you want to re-read history. +3. **Handle your own echoed messages**: Channel messages and DMs are echoed + back to the sender. Your client will receive its own messages. Either + deduplicate by `id` or show them (which confirms delivery). +4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel + (no `#` prefix), the DM tab should be keyed by the **other** user's nick: + if `from` is you, use `to`; if `from` is someone else, use `from`. +5. **Reconnection**: If the poll loop fails with 401, the session is gone. + Create a new session. If it fails with a network error, retry with backoff. + +--- + +## Rate Limiting & Abuse Prevention + +Session creation (`POST /api/v1/session`) will require a +[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token. +This is the primary defense against resource exhaustion — no CAPTCHAs, no +account registration, no IP-based rate limits that punish shared networks. + +### How It Works + +1. Client requests a challenge: `GET /api/v1/challenge` + ```json + → {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"} + ``` +2. Server returns a nonce and a required difficulty (number of leading zero + bits in the SHA-256 hash) +3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)` + has the required number of leading zero bits: + ``` + SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good) + SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good) + ... + SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!) + ``` +4. Client submits the proof with the session request: + ```json + POST /api/v1/session + {"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}} + ``` +5. Server verifies: + - Nonce was issued by this server and hasn't expired + - Nonce hasn't been used before (prevent replay) + - `SHA-256(nonce || ":" || counter)` has the required leading zeros + - If valid, create the session normally + +### Adaptive Difficulty + +The required difficulty scales with server load. Under normal conditions, the +cost is negligible (a few milliseconds of CPU). As concurrent sessions or +session creation rate increases, difficulty rises — making bulk session creation +exponentially more expensive for attackers while remaining cheap for legitimate +single-user connections. + +| Server Load | Difficulty (bits) | Approx. Client CPU | +|--------------------|-------------------|--------------------| +| Normal (< 100/min) | 16 | ~1ms | +| Elevated | 20 | ~15ms | +| High | 24 | ~250ms | +| Under attack | 28+ | ~4s+ | + +Each additional bit of difficulty doubles the expected work. An attacker +creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate +user creating one session needs ~4 seconds once and never again for the +duration of their session. + +### Why Hashcash and Not Rate Limits? + +- **No state to track**: No IP tables, no token buckets, no sliding windows. + The server only needs to verify a hash. +- **Works through NATs and proxies**: Doesn't punish shared IPs (university + campuses, corporate networks, Tor exits). Every client computes their own + proof independently. +- **Cost falls on the requester**: The server's verification cost is constant + (one SHA-256 hash) regardless of difficulty. Only the client does more work. +- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. + No registration, no email, no phone number, no CAPTCHA. Just compute. +- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU + once. A botnet trying to create thousands of sessions pays exponentially more. +- **Language-agnostic**: SHA-256 is available in every programming language. + The proof computation is trivially implementable in any client. + +### Challenge Endpoint (Planned) ``` -GET /api/v1/messages — Single message stream (long-poll, 15s timeout) - All message types: channel, DM, notices, events - Delivers from the calling client's queue - (identified by auth token) - Query params: ?after=&timeout=15 -POST /api/v1/messages — Send any C2S command (dispatched by "command" field) +GET /api/v1/challenge ``` -All client-to-server commands use `POST /api/v1/messages` with a `command` -field. There are no separate endpoints for join, part, nick, topic, etc. - -| Command | Required Fields | Optional Fields | Description | -|-----------|---------------------|-----------------|-------------| -| `PRIVMSG` | `to`, `body` | `meta` | Message to channel (`#name`) or user (nick) | -| `NOTICE` | `to`, `body` | `meta` | Notice (must not trigger auto-reply) | -| `JOIN` | `to` | | Join a channel (creates if nonexistent) | -| `PART` | `to` | `body` | Leave a channel | -| `NICK` | `body` | | Change nickname — `body: ["newnick"]` | -| `TOPIC` | `to`, `body` | | Set channel topic | -| `MODE` | `to`, `params` | | Set channel/user modes | -| `KICK` | `to`, `params` | `body` | Kick user — `params: ["nick"]`, `body: ["reason"]` | -| `PING` | | | Keepalive (server responds with PONG) | -| `PUBKEY` | `body` | | Announce signing key — `body: {"alg":..., "key":...}` | - -Examples: - +**Response:** `200 OK` ```json -{"command": "PRIVMSG", "to": "#channel", "body": ["hello world"]} -{"command": "JOIN", "to": "#channel"} -{"command": "PART", "to": "#channel"} -{"command": "NICK", "body": ["newnick"]} -{"command": "TOPIC", "to": "#channel", "body": ["new topic text"]} -{"command": "PING"} +{ + "nonce": "a1b2c3d4e5f6...", + "difficulty": 20, + "algorithm": "sha256", + "expires": "2026-02-10T20:01:00Z" +} ``` -Messages are immutable — no edit or delete endpoints. +| Field | Type | Description | +|--------------|---------|-------------| +| `nonce` | string | Server-generated random hex string (32+ chars) | +| `difficulty` | integer | Required number of leading zero bits in the hash | +| `algorithm` | string | Hash algorithm (always `sha256` for now) | +| `expires` | string | ISO 8601 expiry time for this challenge | -#### History +**Status:** Not yet implemented. Tracked for post-MVP. -``` -GET /api/v1/history — Fetch history for a target (channel or DM) - Query params: ?target=#channel&before=&limit=50 - For DMs: ?target=nick&before=&limit=50 -``` +--- -#### Channels +## Roadmap -``` -GET /api/v1/channels — List all server channels -GET /api/v1/channels/{name}/members — Channel member list -``` +### Implemented (MVP) -Join and part are handled via `POST /api/v1/messages` with `JOIN` and `PART` -commands (see Messages above). +- [x] Session creation with nick claim +- [x] All core commands: PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING +- [x] IRC message envelope format (command, from, to, body, ts, meta) +- [x] Per-client delivery queues with fan-out +- [x] Long-polling with in-memory broker +- [x] Channel messages and DMs +- [x] Ephemeral channels (deleted when empty) +- [x] NICK change with broadcast +- [x] QUIT with broadcast and cleanup +- [x] Embedded web SPA client +- [x] CLI client (chat-cli) +- [x] SQLite storage with WAL mode +- [x] Docker deployment +- [x] Prometheus metrics endpoint +- [x] Health check endpoint -#### Server Info +### Post-MVP (Planned) -``` -GET /api/v1/server — Server info (name, MOTD) -GET /.well-known/healthcheck.json — Health check -``` +- [ ] **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` +- [ ] **User channel modes** — `+o` (operator), `+v` (voice) +- [ ] **MODE command** — set/query channel and user modes +- [ ] **KICK command** — remove users from channels +- [ ] **Numeric replies** — send IRC numeric codes via the message queue + (001 welcome, 353 NAMES, 332 TOPIC, etc.) +- [ ] **Max message size enforcement** — reject oversized messages +- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) +- [ ] **Multi-client sessions** — add client to existing session + (share nick across devices) -### Federation (Server-to-Server) +### Future (1.0+) -Servers can link to form a network, similar to IRC server linking. Links are -**manually configured** — there is no autodiscovery. +- [ ] **PUBKEY command** — public key distribution +- [ ] **Message signing** — Ed25519 signatures with JCS canonicalization +- [ ] **TOFU key management** — client-side key caching and verification +- [ ] **E2E encryption for DMs** — end-to-end encrypted direct messages + using X25519 key exchange +- [ ] **Federation** — server-to-server linking, message relay, state sync +- [ ] **Postgres support** — for high-traffic deployments +- [ ] **Image/file upload** — inline media via a separate upload endpoint, + referenced in message `meta` +- [ ] **Push notifications** — optional webhook/push for mobile clients + when messages arrive during disconnect +- [ ] **Message search** — full-text search over channel history +- [ ] **User info command** — WHOIS-equivalent for querying user metadata +- [ ] **Connection flood protection** — per-IP connection limits as a + complement to hashcash +- [ ] **Invite system** — `INVITE` command for `+i` channels +- [ ] **Ban system** — channel-level bans by nick pattern -``` -POST /api/v1/federation/link — Establish server link (mutual auth via shared key) -POST /api/v1/federation/relay — Relay messages between linked servers -GET /api/v1/federation/status — Link status -``` +--- -Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK, -UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are -relayed verbatim between servers so users on different servers can share channels. - -### Channel Modes - -Inspired by IRC but simplified: - -| Mode | Meaning | -|------|---------| -| `+i` | Invite-only | -| `+m` | Moderated (only voiced users can send) | -| `+s` | Secret (hidden from channel list) | -| `+t` | Topic locked (only ops can change) | -| `+n` | No external messages | - -User channel modes: `+o` (operator), `+v` (voice) - -### Configuration - -Via environment variables (Viper), following gohttpserver conventions: - -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `8080` | Listen port | -| `DBURL` | `""` | SQLite/Postgres connection string | -| `DEBUG` | `false` | Debug mode | -| `MAX_HISTORY` | `10000` | Max messages per channel history | -| `SESSION_TIMEOUT` | `86400` | Session idle timeout (seconds) | -| `QUEUE_MAX_AGE` | `172800` | Max client queue age in seconds (default 48h) | -| `MAX_MESSAGE_SIZE` | `4096` | Max message body size (bytes) | -| `LONG_POLL_TIMEOUT` | `15` | Long-poll timeout in seconds | -| `MOTD` | `""` | Message of the day | -| `SERVER_NAME` | hostname | Server display name | -| `FEDERATION_KEY` | `""` | Shared key for server linking | - -### Storage - -SQLite by default (single-file, zero-config), with Postgres support for -larger deployments. Tables: - -- `sessions` — user sessions (UUID, nick, created_at) -- `clients` — client records (UUID, session_id, token_hash, last_seen) -- `channels` — channel metadata and modes -- `channel_members` — membership and user modes -- `messages` — message history (rotated per `MAX_HISTORY`) -- `client_queues` — per-client pending delivery queues -- `server_links` — federation peer configuration - -### Project Structure +## Project Structure Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): ``` chat/ ├── cmd/ -│ └── chatd/ -│ └── main.go +│ ├── chatd/ # Server binary entry point +│ │ └── main.go +│ └── chat-cli/ # TUI client +│ ├── main.go # Command handling, poll loop +│ ├── ui.go # tview-based terminal UI +│ └── api/ +│ ├── client.go # HTTP API client library +│ └── types.go # Request/response types ├── internal/ -│ ├── config/ -│ ├── database/ -│ ├── globals/ -│ ├── handlers/ -│ ├── healthcheck/ -│ ├── logger/ -│ ├── middleware/ -│ ├── models/ -│ ├── queue/ -│ └── server/ -├── schema/ -│ ├── message.schema.json -│ ├── c2s/ -│ ├── s2c/ -│ ├── s2s/ -│ └── README.md +│ ├── broker/ # In-memory pub/sub for long-poll notifications +│ │ └── broker.go +│ ├── config/ # Viper-based configuration +│ │ └── config.go +│ ├── db/ # Database access and migrations +│ │ ├── db.go # Connection, migration runner +│ │ ├── queries.go # All SQL queries and data types +│ │ └── schema/ +│ │ └── 001_initial.sql +│ ├── globals/ # Application-wide metadata +│ │ └── globals.go +│ ├── handlers/ # HTTP request handlers +│ │ ├── handlers.go # Deps, JSON response helper +│ │ ├── api.go # All API endpoint handlers +│ │ └── healthcheck.go # Health check handler +│ ├── healthcheck/ # Health check logic +│ │ └── healthcheck.go +│ ├── logger/ # slog-based logging +│ │ └── logger.go +│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) +│ │ └── middleware.go +│ └── server/ # HTTP server, routing, lifecycle +│ ├── server.go # fx lifecycle, Sentry, signal handling +│ ├── routes.go # chi router setup, all routes +│ └── http.go # HTTP timeouts ├── web/ +│ ├── embed.go # go:embed directive for SPA +│ └── dist/ # Built SPA (vanilla JS, no build step) +│ ├── index.html +│ ├── style.css +│ └── app.js +├── schema/ # JSON Schema definitions (planned) ├── go.mod ├── go.sum ├── Makefile @@ -610,48 +2106,95 @@ chat/ ### Required Libraries -Per gohttpserver conventions: +| Purpose | Library | +|------------|---------| +| DI | `go.uber.org/fx` | +| Router | `github.com/go-chi/chi` | +| Logging | `log/slog` (stdlib) | +| Config | `github.com/spf13/viper` | +| Env | `github.com/joho/godotenv/autoload` | +| CORS | `github.com/go-chi/cors` | +| Metrics | `github.com/prometheus/client_golang` | +| DB | `modernc.org/sqlite` + `database/sql` | +| UUIDs | `github.com/google/uuid` | +| Errors | `github.com/getsentry/sentry-go` (optional) | +| TUI Client | `github.com/rivo/tview` + `github.com/gdamore/tcell/v2` | -| Purpose | Library | -|---------|---------| -| DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi` | -| Logging | `log/slog` (stdlib) | -| Config | `github.com/spf13/viper` | -| Env | `github.com/joho/godotenv/autoload` | -| CORS | `github.com/go-chi/cors` | -| Metrics | `github.com/prometheus/client_golang` | -| DB | `modernc.org/sqlite` + `database/sql` | +--- -### Design Principles +## Design Principles 1. **API-first** — the HTTP API is the product. Clients are thin. If you can't - build a working IRC-style TUI client in an afternoon, the API is too complex. + build a working IRC-style TUI client against this API in an afternoon, the + API is too complex. + 2. **No accounts** — identity is a signing key, nick is a display name. No - registration, no passwords. Session creation is instant. -3. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812. - Familiar to anyone who's built IRC clients or bots. + registration, no passwords, no email verification. Session creation is + instant. The cost of entry is a hashcash proof, not bureaucracy. + +3. **IRC semantics over HTTP** — command names and numeric codes from + RFC 1459/2812. If you've built an IRC client or bot, you already know the + command vocabulary. The only new things are the JSON encoding and the + HTTP transport. + 4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol - negotiation. HTTP is universal, proxy-friendly, and works everywhere. + negotiation. HTTP is universal, proxy-friendly, CDN-friendly, and works on + every device and network. Long-polling provides real-time delivery without + any of the complexity of persistent connections. + 5. **Server holds state** — clients are stateless. Reconnect, switch devices, - lose connectivity — your messages are waiting in your client queue. + lose connectivity for hours — your messages are waiting in your client queue. + The server is the source of truth for session state, channel membership, + and message history. + 6. **Structured messages** — JSON with extensible metadata. Bodies are always - objects or arrays for deterministic canonicalization (JCS) and signing. -7. **Immutable messages** — no editing, no deletion. Fits naturally with - cryptographic signatures. + objects or arrays, never raw strings. This enables deterministic + canonicalization (JCS) for signing and multiline messages without escape + sequences. + +7. **Immutable messages** — no editing, no deletion. Ever. This fits naturally + with cryptographic signatures and creates a trustworthy audit trail. IRC + culture already handles corrections inline ("s/typo/fix/"). + 8. **Simple deployment** — single binary, SQLite default, zero mandatory - external dependencies. + external dependencies. `docker run` and you're done. No Redis, no + RabbitMQ, no Kubernetes, no configuration management. + 9. **No eternal logs** — history rotates. Chat should be ephemeral by default. - Channels disappear when empty. -10. **Federation optional** — single server works standalone. Linking is manual - and opt-in. + Channels disappear when empty. Sessions expire when idle. The server does + not aspire to be an archive. + +10. **Federation optional** — a single server works standalone. Linking is + manual and opt-in, like IRC. There is no requirement to participate in a + network. + 11. **Signable messages** — optional Ed25519 signatures with TOFU key - distribution. Servers relay signatures without verification. + distribution. Servers relay signatures without verification. Trust + decisions are made by clients, not servers. + +12. **No magic** — the protocol has no special cases, no content-type + negotiation, no feature flags. Every message uses the same envelope. + Every command goes through the same endpoint. The simplest implementation + is also the correct one. + +--- ## Status -**Implementation in progress.** Core API is functional with SQLite storage and -embedded web client. +**Implementation in progress.** Core API is functional with: + +- SQLite storage with WAL mode +- All core IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING) +- IRC message envelope format with per-client queue fan-out +- Long-polling with in-memory broker +- Embedded web SPA client +- TUI client (chat-cli) +- Docker image +- Prometheus metrics + +See [Roadmap](#roadmap) for what's next. + +--- ## License diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 762b39e..ca22506 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -1,8 +1,9 @@ -// Package chatapi provides a client for the chat server HTTP API. +// Package chatapi provides a client for the chat server API. package chatapi import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -10,21 +11,17 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" ) const ( httpTimeout = 30 * time.Second - pollExtraDelay = 5 + pollExtraTime = 5 httpErrThreshold = 400 ) -// ErrHTTP is returned for non-2xx responses. -var ErrHTTP = errors.New("http error") - -// ErrUnexpectedFormat is returned when the response format is -// not recognised. -var ErrUnexpectedFormat = errors.New("unexpected format") +var errHTTP = errors.New("HTTP error") // Client wraps HTTP calls to the chat server API. type Client struct { @@ -35,20 +32,21 @@ type Client struct { // NewClient creates a new API client. func NewClient(baseURL string) *Client { - return &Client{ + return &Client{ //nolint:exhaustruct // Token set after CreateSession BaseURL: baseURL, - HTTPClient: &http.Client{ + 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( - "POST", "/api/v1/session", + data, err := client.do( + http.MethodPost, + "/api/v1/session", &SessionRequest{Nick: nick}, ) if err != nil { @@ -62,14 +60,16 @@ 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("GET", "/api/v1/state", nil) +func (client *Client) GetState() (*StateResponse, error) { + data, err := client.do( + http.MethodGet, "/api/v1/state", nil, + ) if err != nil { return nil, err } @@ -85,106 +85,110 @@ func (c *Client) GetState() (*StateResponse, error) { } // SendMessage sends a message (any IRC command). -func (c *Client) SendMessage(msg *Message) error { - _, err := c.do("POST", "/api/v1/messages", msg) +func (client *Client) SendMessage(msg *Message) error { + _, err := client.do( + http.MethodPost, "/api/v1/messages", msg, + ) return err } // PollMessages long-polls for new messages. -func (c *Client) PollMessages( - afterID string, +func (client *Client) PollMessages( + afterID int64, timeout int, -) ([]Message, error) { - pollTimeout := time.Duration( - timeout+pollExtraDelay, - ) * time.Second - - client := &http.Client{Timeout: pollTimeout} +) (*PollResult, error) { + pollClient := &http.Client{ //nolint:exhaustruct // defaults fine + Timeout: time.Duration( + timeout+pollExtraTime, + ) * time.Second, + } params := url.Values{} - if afterID != "" { - params.Set("after", afterID) + if afterID > 0 { + params.Set( + "after", + strconv.FormatInt(afterID, 10), + ) } params.Set("timeout", strconv.Itoa(timeout)) - path := "/api/v1/messages" - if len(params) > 0 { - path += "?" + params.Encode() - } + path := "/api/v1/messages?" + params.Encode() - req, err := http.NewRequest( //nolint:noctx // CLI tool - http.MethodGet, c.BaseURL+path, nil, + request, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + 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) //nolint:gosec // URL from user config + 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 { return nil, fmt.Errorf( - "%w: %d: %s", - ErrHTTP, resp.StatusCode, string(data), + "%w %d: %s", + errHTTP, resp.StatusCode, string(data), ) } - return decodeMessages(data) -} - -func decodeMessages(data []byte) ([]Message, error) { - var msgs []Message - - err := json.Unmarshal(data, &msgs) - if err == nil { - return msgs, nil - } - var wrapped MessagesResponse - err2 := json.Unmarshal(data, &wrapped) - if err2 != nil { + err = json.Unmarshal(data, &wrapped) + if err != nil { return nil, fmt.Errorf( - "decode messages: %w (raw: %s)", - err, string(data), + "decode messages: %w", err, ) } - return wrapped.Messages, nil + return &PollResult{ + Messages: wrapped.Messages, + LastID: wrapped.LastID, + }, nil } -// JoinChannel joins a channel via the unified command -// endpoint. -func (c *Client) JoinChannel(channel string) error { - return c.SendMessage( - &Message{Command: "JOIN", To: channel}, +// JoinChannel joins a 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 via the unified command -// endpoint. -func (c *Client) PartChannel(channel string) error { - return c.SendMessage( - &Message{Command: "PART", To: channel}, +// PartChannel leaves a 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("GET", "/api/v1/channels", nil) +func (client *Client) ListChannels() ( + []Channel, error, +) { + data, err := client.do( + http.MethodGet, "/api/v1/channels", nil, + ) if err != nil { return nil, err } @@ -193,20 +197,26 @@ 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) { - path := "/api/v1/channels/" + - url.PathEscape(channel) + "/members" + name := strings.TrimPrefix(channel, "#") - data, err := c.do("GET", path, nil) + data, err := client.do( + http.MethodGet, + "/api/v1/channels/"+url.PathEscape(name)+ + "/members", + nil, + ) if err != nil { return nil, err } @@ -216,8 +226,7 @@ func (c *Client) GetMembers( err = json.Unmarshal(data, &members) if err != nil { return nil, fmt.Errorf( - "%w: members: %s", - ErrUnexpectedFormat, string(data), + "unexpected members format: %w", err, ) } @@ -225,8 +234,12 @@ func (c *Client) GetMembers( } // GetServerInfo returns server info. -func (c *Client) GetServerInfo() (*ServerInfo, error) { - data, err := c.do("GET", "/api/v1/server", nil) +func (client *Client) GetServerInfo() ( + *ServerInfo, error, +) { + data, err := client.do( + http.MethodGet, "/api/v1/server", nil, + ) if err != nil { return nil, err } @@ -235,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) { @@ -256,20 +271,27 @@ func (c *Client) do( bodyReader = bytes.NewReader(data) } - req, err := http.NewRequest( //nolint:noctx // CLI tool - method, c.BaseURL+path, bodyReader, + request, err := http.NewRequestWithContext( + context.Background(), + method, + 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) //nolint:gosec // URL from user config + resp, err := client.HTTPClient.Do(request) if err != nil { return nil, fmt.Errorf("http: %w", err) } @@ -283,8 +305,8 @@ func (c *Client) do( if resp.StatusCode >= httpErrThreshold { return data, fmt.Errorf( - "%w: %d: %s", - ErrHTTP, resp.StatusCode, string(data), + "%w %d: %s", + errHTTP, resp.StatusCode, string(data), ) } diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go index 011ad8e..718bf76 100644 --- a/cmd/chat-cli/api/types.go +++ b/cmd/chat-cli/api/types.go @@ -7,20 +7,18 @@ type SessionRequest struct { Nick string `json:"nick"` } -// SessionResponse is the response from POST /api/v1/session. +// SessionResponse is the response from session creation. type SessionResponse struct { - SessionID string `json:"sessionId"` - ClientID string `json:"clientId"` - Nick string `json:"nick"` - Token string `json:"token"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Token string `json:"token"` } // StateResponse is the response from GET /api/v1/state. type StateResponse struct { - SessionID string `json:"sessionId"` - ClientID string `json:"clientId"` - Nick string `json:"nick"` - Channels []string `json:"channels"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Channels []string `json:"channels"` } // Message represents a chat message envelope. @@ -35,22 +33,21 @@ type Message struct { Meta any `json:"meta,omitempty"` } -// BodyLines returns the body as a slice of strings (for text -// messages). +// 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 } @@ -74,6 +71,13 @@ type ServerInfo struct { // MessagesResponse wraps polling results. type MessagesResponse struct { Messages []Message `json:"messages"` + LastID int64 `json:"lastId"` +} + +// PollResult wraps the poll response including the cursor. +type PollResult struct { + Messages []Message + LastID int64 } // ParseTS parses the message timestamp. diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index d57b359..f5b22f3 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -1,10 +1,9 @@ -// Package main implements chat-cli, an IRC-style terminal client. +// Package main is the entry point for the chat-cli client. package main import ( "fmt" "os" - "strconv" "strings" "sync" "time" @@ -13,9 +12,10 @@ import ( ) const ( - pollTimeoutSec = 15 - retryDelay = 2 * time.Second - maxNickLength = 32 + splitParts = 2 + pollTimeout = 15 + pollRetry = 2 * time.Second + timeFormat = "15:04" ) // App holds the application state. @@ -25,14 +25,14 @@ type App struct { mu sync.Mutex nick string - target string // current target (#channel or nick for DM) + target string connected bool - lastMsgID string + lastQID int64 stopPoll chan struct{} } func main() { - app := &App{ + app := &App{ //nolint:exhaustruct ui: NewUI(), nick: "guest", } @@ -41,17 +41,17 @@ func main() { app.ui.SetStatus(app.nick, "", "disconnected") app.ui.AddStatus( - "Welcome to chat-cli \u2014 an IRC-style client", + "Welcome to chat-cli — an IRC-style client", ) app.ui.AddStatus( - "Type [yellow]/connect [white] " + - "to begin, or [yellow]/help[white] for commands", + "Type [yellow]/connect " + + "[white] to begin, " + + "or [yellow]/help[white] for commands", ) err := app.ui.Run() if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } @@ -63,14 +63,9 @@ func (a *App) handleInput(text string) { return } - a.sendPlainText(text) -} - -func (a *App) sendPlainText(text string) { a.mu.Lock() target := a.target connected := a.connected - nick := a.nick a.mu.Unlock() if !connected { @@ -90,32 +85,33 @@ func (a *App) sendPlainText(text string) { return } - err := a.client.SendMessage(&api.Message{ + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct Command: "PRIVMSG", To: target, Body: []string{text}, }) if err != nil { a.ui.AddStatus( - fmt.Sprintf("[red]Send error: %v", err), + "[red]Send error: " + err.Error(), ) return } - ts := time.Now().Format("15:04") + timestamp := time.Now().Format(timeFormat) - a.ui.AddLine( - target, - fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - ts, nick, text, - ), - ) + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, nick, text, + )) } -func (a *App) handleCommand(text string) { //nolint:cyclop // command dispatch - parts := strings.SplitN(text, " ", 2) //nolint:mnd // split into cmd+args +func (a *App) handleCommand(text string) { + parts := strings.SplitN(text, " ", splitParts) cmd := strings.ToLower(parts[0]) args := "" @@ -123,6 +119,10 @@ func (a *App) handleCommand(text string) { //nolint:cyclop // command dispatch args = parts[1] } + a.dispatchCommand(cmd, args) +} + +func (a *App) dispatchCommand(cmd, args string) { switch cmd { case "/connect": a.cmdConnect(args) @@ -166,9 +166,7 @@ func (a *App) cmdConnect(serverURL string) { serverURL = strings.TrimRight(serverURL, "/") - a.ui.AddStatus( - fmt.Sprintf("Connecting to %s...", serverURL), - ) + a.ui.AddStatus("Connecting to " + serverURL + "...") a.mu.Lock() nick := a.nick @@ -178,11 +176,9 @@ func (a *App) cmdConnect(serverURL string) { resp, err := client.CreateSession(nick) if err != nil { - a.ui.AddStatus( - fmt.Sprintf( - "[red]Connection failed: %v", err, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Connection failed: %v", err, + )) return } @@ -191,15 +187,13 @@ func (a *App) cmdConnect(serverURL string) { a.client = client a.nick = resp.Nick a.connected = true - a.lastMsgID = "" + a.lastQID = 0 a.mu.Unlock() - a.ui.AddStatus( - fmt.Sprintf( - "[green]Connected! Nick: %s, Session: %s", - resp.Nick, resp.SessionID, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[green]Connected! Nick: %s, Session: %d", + resp.Nick, resp.ID, + )) a.ui.SetStatus(resp.Nick, "", "connected") a.stopPoll = make(chan struct{}) @@ -209,7 +203,9 @@ func (a *App) cmdConnect(serverURL string) { func (a *App) cmdNick(nick string) { if nick == "" { - a.ui.AddStatus("[red]Usage: /nick ") + a.ui.AddStatus( + "[red]Usage: /nick ", + ) return } @@ -224,25 +220,21 @@ func (a *App) cmdNick(nick string) { a.mu.Unlock() a.ui.AddStatus( - fmt.Sprintf( - "Nick set to %s (will be used on connect)", - nick, - ), + "Nick set to " + nick + + " (will be used on connect)", ) return } - err := a.client.SendMessage(&api.Message{ + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct Command: "NICK", Body: []string{nick}, }) if err != nil { - a.ui.AddStatus( - fmt.Sprintf( - "[red]Nick change failed: %v", err, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Nick change failed: %v", err, + )) return } @@ -253,14 +245,14 @@ func (a *App) cmdNick(nick string) { a.mu.Unlock() a.ui.SetStatus(nick, target, "connected") - a.ui.AddStatus( - "Nick changed to " + nick, - ) + a.ui.AddStatus("Nick changed to " + nick) } func (a *App) cmdJoin(channel string) { if channel == "" { - a.ui.AddStatus("[red]Usage: /join #channel") + a.ui.AddStatus( + "[red]Usage: /join #channel", + ) return } @@ -281,9 +273,9 @@ func (a *App) cmdJoin(channel string) { err := a.client.JoinChannel(channel) if err != nil { - a.ui.AddStatus( - fmt.Sprintf("[red]Join failed: %v", err), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Join failed: %v", err, + )) return } @@ -294,8 +286,7 @@ func (a *App) cmdJoin(channel string) { a.mu.Unlock() a.ui.SwitchToBuffer(channel) - a.ui.AddLine( - channel, + a.ui.AddLine(channel, "[yellow]*** Joined "+channel, ) a.ui.SetStatus(nick, channel, "connected") @@ -303,7 +294,6 @@ func (a *App) cmdJoin(channel string) { func (a *App) cmdPart(channel string) { a.mu.Lock() - if channel == "" { channel = a.target } @@ -311,7 +301,8 @@ func (a *App) cmdPart(channel string) { connected := a.connected a.mu.Unlock() - if channel == "" || !strings.HasPrefix(channel, "#") { + if channel == "" || + !strings.HasPrefix(channel, "#") { a.ui.AddStatus("[red]No channel to part") return @@ -325,20 +316,18 @@ func (a *App) cmdPart(channel string) { err := a.client.PartChannel(channel) if err != nil { - a.ui.AddStatus( - fmt.Sprintf("[red]Part failed: %v", err), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Part failed: %v", err, + )) return } - a.ui.AddLine( - channel, + a.ui.AddLine(channel, "[yellow]*** Left "+channel, ) a.mu.Lock() - if a.target == channel { a.target = "" } @@ -351,9 +340,11 @@ func (a *App) cmdPart(channel string) { } func (a *App) cmdMsg(args string) { - parts := strings.SplitN(args, " ", 2) //nolint:mnd // split into target+text - if len(parts) < 2 { //nolint:mnd // min args - a.ui.AddStatus("[red]Usage: /msg ") + parts := strings.SplitN(args, " ", splitParts) + if len(parts) < splitParts { + a.ui.AddStatus( + "[red]Usage: /msg ", + ) return } @@ -371,33 +362,32 @@ 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}, }) if err != nil { - a.ui.AddStatus( - fmt.Sprintf("[red]Send failed: %v", err), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Send failed: %v", err, + )) return } - ts := time.Now().Format("15:04") + timestamp := time.Now().Format(timeFormat) - a.ui.AddLine( - target, - fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - ts, nick, text, - ), - ) + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, nick, text, + )) } func (a *App) cmdQuery(nick string) { if nick == "" { - a.ui.AddStatus("[red]Usage: /query ") + a.ui.AddStatus( + "[red]Usage: /query ", + ) return } @@ -430,32 +420,28 @@ 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, }) if err != nil { - a.ui.AddStatus( - fmt.Sprintf( - "[red]Topic query failed: %v", err, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Topic query failed: %v", err, + )) } return } - err := a.client.SendMessage(&api.Message{ + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct Command: "TOPIC", To: target, Body: []string{args}, }) if err != nil { - a.ui.AddStatus( - fmt.Sprintf( - "[red]Topic set failed: %v", err, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Topic set failed: %v", err, + )) } } @@ -479,20 +465,17 @@ func (a *App) cmdNames() { members, err := a.client.GetMembers(target) if err != nil { - a.ui.AddStatus( - fmt.Sprintf("[red]Names failed: %v", err), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]Names failed: %v", err, + )) return } - a.ui.AddLine( - target, - fmt.Sprintf( - "[cyan]*** Members of %s: %s", - target, strings.Join(members, " "), - ), - ) + a.ui.AddLine(target, fmt.Sprintf( + "[cyan]*** Members of %s: %s", + target, strings.Join(members, " "), + )) } func (a *App) cmdList() { @@ -508,9 +491,9 @@ func (a *App) cmdList() { channels, err := a.client.ListChannels() if err != nil { - a.ui.AddStatus( - fmt.Sprintf("[red]List failed: %v", err), - ) + a.ui.AddStatus(fmt.Sprintf( + "[red]List failed: %v", err, + )) return } @@ -518,12 +501,10 @@ func (a *App) cmdList() { a.ui.AddStatus("[cyan]*** Channel list:") for _, ch := range channels { - a.ui.AddStatus( - fmt.Sprintf( - " %s (%d members) %s", - ch.Name, ch.Members, ch.Topic, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + " %s (%d members) %s", + ch.Name, ch.Members, ch.Topic, + )) } a.ui.AddStatus("[cyan]*** End of channel list") @@ -531,27 +512,33 @@ func (a *App) cmdList() { func (a *App) cmdWindow(args string) { if args == "" { - a.ui.AddStatus("[red]Usage: /window ") + a.ui.AddStatus( + "[red]Usage: /window ", + ) return } - n, _ := strconv.Atoi(args) - a.ui.SwitchBuffer(n) + var bufIndex int + + _, _ = fmt.Sscanf(args, "%d", &bufIndex) + + 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 a.mu.Unlock() - a.ui.SetStatus(nick, buf.Name, "connected") + a.ui.SetStatus( + nick, buf.Name, "connected", + ) } else { a.ui.SetStatus(nick, "", "connected") } @@ -563,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 ) } @@ -578,18 +565,18 @@ func (a *App) cmdQuit() { func (a *App) cmdHelp() { help := []string{ "[cyan]*** chat-cli commands:", - " /connect \u2014 Connect to server", - " /nick \u2014 Change nickname", - " /join #channel \u2014 Join channel", - " /part [#chan] \u2014 Leave channel", - " /msg \u2014 Send DM", - " /query \u2014 Open DM window", - " /topic [text] \u2014 View/set topic", - " /names \u2014 List channel members", - " /list \u2014 List channels", - " /window \u2014 Switch buffer (Alt+0-9)", - " /quit \u2014 Disconnect and exit", - " /help \u2014 This help", + " /connect — Connect to server", + " /nick — Change nickname", + " /join #channel — Join channel", + " /part [#chan] — Leave channel", + " /msg — Send DM", + " /query — Open DM window", + " /topic [text] — View/set topic", + " /names — List channel members", + " /list — List channels", + " /window — Switch buffer", + " /quit — Disconnect and exit", + " /help — This help", " Plain text sends to current target.", } @@ -609,38 +596,36 @@ func (a *App) pollLoop() { a.mu.Lock() client := a.client - lastID := a.lastMsgID + lastQID := a.lastQID a.mu.Unlock() if client == nil { return } - msgs, err := client.PollMessages( - lastID, pollTimeoutSec, + result, err := client.PollMessages( + lastQID, pollTimeout, ) if err != nil { - time.Sleep(retryDelay) + time.Sleep(pollRetry) continue } - for i := range msgs { - a.handleServerMessage(&msgs[i]) + if result.LastID > 0 { + a.mu.Lock() + a.lastQID = result.LastID + a.mu.Unlock() + } - if msgs[i].ID != "" { - a.mu.Lock() - a.lastMsgID = msgs[i].ID - a.mu.Unlock() - } + for i := range result.Messages { + a.handleServerMessage(&result.Messages[i]) } } } -func (a *App) handleServerMessage( - msg *api.Message, -) { - ts := a.parseMessageTS(msg) +func (a *App) handleServerMessage(msg *api.Message) { + timestamp := a.formatTS(msg) a.mu.Lock() myNick := a.nick @@ -648,37 +633,34 @@ func (a *App) handleServerMessage( switch msg.Command { case "PRIVMSG": - a.handlePrivmsgMsg(msg, ts, myNick) + a.handlePrivmsgEvent(msg, timestamp, myNick) case "JOIN": - a.handleJoinMsg(msg, ts) + a.handleJoinEvent(msg, timestamp) case "PART": - a.handlePartMsg(msg, ts) + a.handlePartEvent(msg, timestamp) case "QUIT": - a.handleQuitMsg(msg, ts) + a.handleQuitEvent(msg, timestamp) case "NICK": - a.handleNickMsg(msg, ts, myNick) + a.handleNickEvent(msg, timestamp, myNick) case "NOTICE": - a.handleNoticeMsg(msg, ts) + a.handleNoticeEvent(msg, timestamp) case "TOPIC": - a.handleTopicMsg(msg, ts) + a.handleTopicEvent(msg, timestamp) default: - a.handleDefaultMsg(msg, ts) + a.handleDefaultEvent(msg, timestamp) } } -func (a *App) parseMessageTS(msg *api.Message) string { +func (a *App) formatTS(msg *api.Message) string { if msg.TS != "" { - t := msg.ParseTS() - - return t.In(time.Local).Format("15:04") //nolint:gosmopolitan // CLI uses local time + return msg.ParseTS().UTC().Format(timeFormat) } - return time.Now().Format("15:04") + return time.Now().Format(timeFormat) } -func (a *App) handlePrivmsgMsg( - msg *api.Message, - ts, myNick string, +func (a *App) handlePrivmsgEvent( + msg *api.Message, timestamp, myNick string, ) { lines := msg.BodyLines() text := strings.Join(lines, " ") @@ -692,37 +674,29 @@ func (a *App) handlePrivmsgMsg( target = msg.From } - a.ui.AddLine( - target, - fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - ts, msg.From, text, - ), - ) + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, msg.From, text, + )) } -func (a *App) handleJoinMsg( - msg *api.Message, ts string, +func (a *App) handleJoinEvent( + msg *api.Message, timestamp string, ) { - target := msg.To - if target == "" { + if msg.To == "" { return } - a.ui.AddLine( - target, - fmt.Sprintf( - "[gray]%s [yellow]*** %s has joined %s", - ts, msg.From, target, - ), - ) + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has joined %s", + timestamp, msg.From, msg.To, + )) } -func (a *App) handlePartMsg( - msg *api.Message, ts string, +func (a *App) handlePartEvent( + msg *api.Message, timestamp string, ) { - target := msg.To - if target == "" { + if msg.To == "" { return } @@ -730,49 +704,39 @@ func (a *App) handlePartMsg( reason := strings.Join(lines, " ") if reason != "" { - a.ui.AddLine( - target, - fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s (%s)", - ts, msg.From, target, reason, - ), - ) + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has left %s (%s)", + timestamp, msg.From, msg.To, reason, + )) } else { - a.ui.AddLine( - target, - fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s", - ts, msg.From, target, - ), - ) + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has left %s", + timestamp, msg.From, msg.To, + )) } } -func (a *App) handleQuitMsg( - msg *api.Message, ts string, +func (a *App) handleQuitEvent( + msg *api.Message, timestamp string, ) { lines := msg.BodyLines() reason := strings.Join(lines, " ") if reason != "" { - a.ui.AddStatus( - fmt.Sprintf( - "[gray]%s [yellow]*** %s has quit (%s)", - ts, msg.From, reason, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s has quit (%s)", + timestamp, msg.From, reason, + )) } else { - a.ui.AddStatus( - fmt.Sprintf( - "[gray]%s [yellow]*** %s has quit", - ts, msg.From, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s has quit", + timestamp, msg.From, + )) } } -func (a *App) handleNickMsg( - msg *api.Message, ts, myNick string, +func (a *App) handleNickEvent( + msg *api.Message, timestamp, myNick string, ) { lines := msg.BodyLines() @@ -784,36 +748,33 @@ func (a *App) handleNickMsg( if msg.From == myNick && newNick != "" { a.mu.Lock() a.nick = newNick + target := a.target a.mu.Unlock() a.ui.SetStatus(newNick, target, "connected") } - a.ui.AddStatus( - fmt.Sprintf( - "[gray]%s [yellow]*** %s is now known as %s", - ts, msg.From, newNick, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s is now known as %s", + timestamp, msg.From, newNick, + )) } -func (a *App) handleNoticeMsg( - msg *api.Message, ts string, +func (a *App) handleNoticeEvent( + 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, - ), - ) + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [magenta]--%s-- %s", + timestamp, msg.From, text, + )) } -func (a *App) handleTopicMsg( - msg *api.Message, ts string, +func (a *App) handleTopicEvent( + msg *api.Message, timestamp string, ) { if msg.To == "" { return @@ -822,29 +783,22 @@ func (a *App) handleTopicMsg( lines := msg.BodyLines() text := strings.Join(lines, " ") - a.ui.AddLine( - msg.To, - fmt.Sprintf( - "[gray]%s [cyan]*** %s set topic: %s", - ts, msg.From, text, - ), - ) + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [cyan]*** %s set topic: %s", + timestamp, msg.From, text, + )) } -func (a *App) handleDefaultMsg( - msg *api.Message, ts string, +func (a *App) handleDefaultEvent( + msg *api.Message, timestamp string, ) { lines := msg.BodyLines() text := strings.Join(lines, " ") - if text == "" { - return - } - - a.ui.AddStatus( - fmt.Sprintf( + if text != "" { + a.ui.AddStatus(fmt.Sprintf( "[gray]%s [white][%s] %s", - ts, msg.Command, text, - ), - ) + timestamp, msg.Command, text, + )) + } } diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index 40f55b3..a0f1bbb 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -31,85 +31,89 @@ 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}, }, } - ui.setupMessages() - ui.setupStatusBar() - ui.setupInput() - ui.setupKeybindings() - ui.setupLayout() + ui.initMessages() + ui.initStatusBar() + ui.initInput() + ui.initKeyCapture() + + ui.layout = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(ui.messages, 0, 1, false). + AddItem(ui.statusBar, 1, 0, false). + AddItem(ui.input, 1, 0, true) + + ui.app.SetRoot(ui.layout, true) + ui.app.SetFocus(ui.input) return 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. - func (ui *UI) Stop() { ui.app.Stop() } // OnInput sets the callback for user input. - func (ui *UI) OnInput(fn func(string)) { ui.onInput = fn } // AddLine adds a line to the specified buffer. - -func (ui *UI) AddLine(bufferName string, line string) { +func (ui *UI) AddLine(bufferName, line string) { ui.app.QueueUpdateDraw(func() { buf := ui.getOrCreateBuffer(bufferName) buf.Lines = append(buf.Lines, line) - // Mark unread if not currently viewing this buffer. - if ui.buffers[ui.currentBuffer] != buf { + cur := ui.buffers[ui.currentBuffer] + if cur != buf { buf.Unread++ - ui.refreshStatus() + ui.refreshStatusBar() } - // If viewing this buffer, append to display. - if ui.buffers[ui.currentBuffer] == buf { + if cur == buf { _, _ = fmt.Fprintln(ui.messages, line) } }) } -// AddStatus adds a line to the status buffer (buffer 0). - +// AddStatus adds a line to the status buffer. func (ui *UI) AddStatus(line string) { ts := time.Now().Format("15:04") - ui.AddLine( "(status)", - fmt.Sprintf("[gray]%s[white] %s", ts, line), + "[gray]"+ts+"[white] "+line, ) } // 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() @@ -119,12 +123,12 @@ func (ui *UI) SwitchBuffer(n int) { } ui.messages.ScrollToEnd() - ui.refreshStatus() + ui.refreshStatusBar() }) } -// SwitchToBuffer switches to the named buffer, creating it - +// SwitchToBuffer switches to named buffer, creating if +// needed. func (ui *UI) SwitchToBuffer(name string) { ui.app.QueueUpdateDraw(func() { buf := ui.getOrCreateBuffer(name) @@ -146,28 +150,25 @@ func (ui *UI) SwitchToBuffer(name string) { } ui.messages.ScrollToEnd() - ui.refreshStatus() + ui.refreshStatusBar() }) } // SetStatus updates the status bar text. - func (ui *UI) SetStatus( nick, target, connStatus string, ) { ui.app.QueueUpdateDraw(func() { - ui.refreshStatusWith(nick, target, connStatus) + ui.renderStatusBar(nick, target, connStatus) }) } // BufferCount returns the number of buffers. - func (ui *UI) BufferCount() int { return len(ui.buffers) } -// BufferIndex returns the index of a named buffer, or -1. - +// BufferIndex returns the index of a named buffer. func (ui *UI) BufferIndex(name string) int { for i, buf := range ui.buffers { if buf.Name == name { @@ -178,7 +179,7 @@ func (ui *UI) BufferIndex(name string) int { return -1 } -func (ui *UI) setupMessages() { +func (ui *UI) initMessages() { ui.messages = tview.NewTextView(). SetDynamicColors(true). SetScrollable(true). @@ -189,14 +190,14 @@ func (ui *UI) setupMessages() { ui.messages.SetBorder(false) } -func (ui *UI) setupStatusBar() { +func (ui *UI) initStatusBar() { ui.statusBar = tview.NewTextView(). SetDynamicColors(true) ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetTextColor(tcell.ColorWhite) } -func (ui *UI) setupInput() { +func (ui *UI) initInput() { ui.input = tview.NewInputField(). SetFieldBackgroundColor(tcell.ColorBlack). SetFieldTextColor(tcell.ColorWhite) @@ -219,7 +220,7 @@ func (ui *UI) setupInput() { }) } -func (ui *UI) setupKeybindings() { +func (ui *UI) initKeyCapture() { ui.app.SetInputCapture( func(event *tcell.EventKey) *tcell.EventKey { if event.Modifiers()&tcell.ModAlt == 0 { @@ -239,34 +240,21 @@ func (ui *UI) setupKeybindings() { ) } -func (ui *UI) setupLayout() { - ui.layout = tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(ui.messages, 0, 1, false). - AddItem(ui.statusBar, 1, 0, false). - AddItem(ui.input, 1, 0, true) - - ui.app.SetRoot(ui.layout, true) - ui.app.SetFocus(ui.input) +func (ui *UI) refreshStatusBar() { + // Placeholder; full refresh needs nick/target context. } -// if needed. - -func (ui *UI) refreshStatus() { - // Rebuilt from app state by parent QueueUpdateDraw. -} - -func (ui *UI) refreshStatusWith( +func (ui *UI) renderStatusBar( nick, target, connStatus string, ) { var unreadParts []string for i, buf := range ui.buffers { if buf.Unread > 0 { - unreadParts = append( - unreadParts, + unreadParts = append(unreadParts, fmt.Sprintf( - "%d:%s(%d)", i, buf.Name, buf.Unread, + "%d:%s(%d)", + i, buf.Name, buf.Unread, ), ) } @@ -286,8 +274,8 @@ func (ui *UI) refreshStatusWith( ui.statusBar.Clear() - _, _ = fmt.Fprintf( - ui.statusBar, " [%s] %s %s %s%s", + _, _ = fmt.Fprintf(ui.statusBar, + " [%s] %s %s %s%s", connStatus, nick, bufInfo, target, unread, ) } @@ -299,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 diff --git a/internal/broker/broker.go b/internal/broker/broker.go new file mode 100644 index 0000000..6974110 --- /dev/null +++ b/internal/broker/broker.go @@ -0,0 +1,73 @@ +// Package broker provides an in-memory pub/sub for long-poll notifications. +package broker + +import ( + "sync" +) + +// Broker notifies waiting clients when new messages are available. +type Broker struct { + mu sync.Mutex + listeners map[int64][]chan struct{} +} + +// New creates a new Broker. +func New() *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. +func (b *Broker) Wait(userID int64) chan struct{} { + waitCh := make(chan struct{}, 1) + + b.mu.Lock() + b.listeners[userID] = append( + b.listeners[userID], waitCh, + ) + b.mu.Unlock() + + return waitCh +} + +// Notify wakes up all waiting clients for a user. +func (b *Broker) Notify(userID int64) { + b.mu.Lock() + waiters := b.listeners[userID] + delete(b.listeners, userID) + b.mu.Unlock() + + for _, waiter := range waiters { + select { + case waiter <- struct{}{}: + default: + } + } +} + +// Remove removes a specific wait channel (for cleanup on timeout). +func (b *Broker) Remove( + userID int64, + waitCh chan struct{}, +) { + b.mu.Lock() + defer b.mu.Unlock() + + waiters := b.listeners[userID] + + for i, waiter := range waiters { + if waiter == waitCh { + b.listeners[userID] = append( + waiters[:i], waiters[i+1:]..., + ) + + break + } + } + + if len(b.listeners[userID]) == 0 { + delete(b.listeners, userID) + } +} diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go new file mode 100644 index 0000000..2d35013 --- /dev/null +++ b/internal/broker/broker_test.go @@ -0,0 +1,121 @@ +package broker_test + +import ( + "sync" + "testing" + "time" + + "git.eeqj.de/sneak/chat/internal/broker" +) + +func TestNewBroker(t *testing.T) { + t.Parallel() + + brk := broker.New() + if brk == nil { + t.Fatal("expected non-nil broker") + } +} + +func TestWaitAndNotify(t *testing.T) { + t.Parallel() + + brk := broker.New() + waitCh := brk.Wait(1) + + go func() { + time.Sleep(10 * time.Millisecond) + brk.Notify(1) + }() + + select { + case <-waitCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} + +func TestNotifyWithoutWaiters(t *testing.T) { + t.Parallel() + + brk := broker.New() + brk.Notify(42) // should not panic. +} + +func TestRemove(t *testing.T) { + t.Parallel() + + brk := broker.New() + waitCh := brk.Wait(1) + + brk.Remove(1, waitCh) + + brk.Notify(1) + + select { + case <-waitCh: + t.Fatal("should not receive after remove") + case <-time.After(50 * time.Millisecond): + } +} + +func TestMultipleWaiters(t *testing.T) { + t.Parallel() + + brk := broker.New() + waitCh1 := brk.Wait(1) + waitCh2 := brk.Wait(1) + + brk.Notify(1) + + select { + case <-waitCh1: + case <-time.After(time.Second): + t.Fatal("ch1 timeout") + } + + select { + case <-waitCh2: + case <-time.After(time.Second): + t.Fatal("ch2 timeout") + } +} + +func TestConcurrentWaitNotify(t *testing.T) { + t.Parallel() + + brk := broker.New() + + var waitGroup sync.WaitGroup + + const concurrency = 100 + + for idx := range concurrency { + waitGroup.Add(1) + + go func(uid int64) { + defer waitGroup.Done() + + waitCh := brk.Wait(uid) + + brk.Notify(uid) + + select { + case <-waitCh: + case <-time.After(time.Second): + t.Error("timeout") + } + }(int64(idx % 10)) + } + + waitGroup.Wait() +} + +func TestRemoveNonexistent(t *testing.T) { + t.Parallel() + + brk := broker.New() + waitCh := make(chan struct{}, 1) + + brk.Remove(999, waitCh) // should not panic. +} diff --git a/internal/config/config.go b/internal/config/config.go index 7820a6d..5999266 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,7 +41,9 @@ type Config struct { } // 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 @@ -74,7 +76,7 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) { } } - s := &Config{ + cfg := &Config{ DBURL: viper.GetString("DBURL"), Debug: viper.GetBool("DEBUG"), Port: viper.GetInt("PORT"), @@ -92,10 +94,10 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) { params: ¶ms, } - 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 } diff --git a/internal/db/db.go b/internal/db/db.go index 5062827..20ed77a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -11,20 +11,16 @@ import ( "sort" "strconv" "strings" - "time" "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/logger" - "git.eeqj.de/sneak/chat/internal/models" "go.uber.org/fx" - _ "github.com/joho/godotenv/autoload" // loads .env file - _ "modernc.org/sqlite" // SQLite driver + _ "github.com/joho/godotenv/autoload" // .env + _ "modernc.org/sqlite" // driver ) -const ( - minMigrationParts = 2 -) +const minMigrationParts = 2 // SchemaFiles contains embedded SQL migration files. // @@ -39,527 +35,95 @@ type Params struct { Config *config.Config } -// Database manages the SQLite database connection and migrations. +// 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 instance and registers lifecycle hooks. -func New(lc fx.Lifecycle, params Params) (*Database, error) { - s := new(Database) - s.params = ¶ms - s.log = params.Logger.Get() +// New creates a new Database and registers lifecycle hooks. +func New( + lifecycle fx.Lifecycle, + params Params, +) (*Database, error) { + database := &Database{ //nolint:exhaustruct // conn set in OnStart + params: ¶ms, + 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 -} - -// NewTest creates a Database for testing, bypassing fx lifecycle. -// It connects to the given DSN and runs all migrations. -func NewTest(dsn string) (*Database, error) { - d, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, err - } - - s := &Database{ - db: d, - log: slog.Default(), - } - - // Item 9: Enable foreign keys - _, err = d.Exec("PRAGMA foreign_keys = ON") //nolint:noctx // no context in sql.Open path - if err != nil { - _ = d.Close() - - return nil, fmt.Errorf("enable foreign keys: %w", err) - } - - ctx := context.Background() - - err = s.runMigrations(ctx) - if err != nil { - _ = d.Close() - - return nil, err - } - - 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 } -// Hydrate injects the database reference into any model that -// embeds Base. -func (s *Database) Hydrate(m interface{ SetDB(d models.DB) }) { - m.SetDB(s) -} - -// GetUserByID looks up a user by their ID. -func (s *Database) GetUserByID( - ctx context.Context, - id string, -) (*models.User, error) { - u := &models.User{} - s.Hydrate(u) - - err := s.db.QueryRowContext(ctx, ` - SELECT id, nick, password_hash, created_at, updated_at, last_seen_at - FROM users WHERE id = ?`, - id, - ).Scan( - &u.ID, &u.Nick, &u.PasswordHash, - &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt, - ) - if err != nil { - return nil, err - } - - return u, nil -} - -// GetChannelByID looks up a channel by its ID. -func (s *Database) GetChannelByID( - ctx context.Context, - id string, -) (*models.Channel, error) { - c := &models.Channel{} - s.Hydrate(c) - - err := s.db.QueryRowContext(ctx, ` - SELECT id, name, topic, modes, created_at, updated_at - FROM channels WHERE id = ?`, - id, - ).Scan( - &c.ID, &c.Name, &c.Topic, &c.Modes, - &c.CreatedAt, &c.UpdatedAt, - ) - if err != nil { - return nil, err - } - - return c, nil -} - -// GetUserByNickModel looks up a user by their nick. -func (s *Database) GetUserByNickModel( - ctx context.Context, - nick string, -) (*models.User, error) { - u := &models.User{} - s.Hydrate(u) - - err := s.db.QueryRowContext(ctx, ` - SELECT id, nick, password_hash, created_at, updated_at, last_seen_at - FROM users WHERE nick = ?`, - nick, - ).Scan( - &u.ID, &u.Nick, &u.PasswordHash, - &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt, - ) - if err != nil { - return nil, err - } - - return u, nil -} - -// GetUserByTokenModel looks up a user by their auth token. -func (s *Database) GetUserByTokenModel( - ctx context.Context, - token string, -) (*models.User, error) { - u := &models.User{} - s.Hydrate(u) - - err := s.db.QueryRowContext(ctx, ` - SELECT u.id, u.nick, u.password_hash, - u.created_at, u.updated_at, u.last_seen_at - FROM users u - JOIN auth_tokens t ON t.user_id = u.id - WHERE t.token = ?`, - token, - ).Scan( - &u.ID, &u.Nick, &u.PasswordHash, - &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt, - ) - if err != nil { - return nil, err - } - - return u, nil -} - -// DeleteAuthToken removes an auth token from the database. -func (s *Database) DeleteAuthToken( - ctx context.Context, - token string, -) error { - _, err := s.db.ExecContext(ctx, - `DELETE FROM auth_tokens WHERE token = ?`, token, - ) - - return err -} - -// UpdateUserLastSeen updates the last_seen_at timestamp for a user. -func (s *Database) UpdateUserLastSeen( - ctx context.Context, - userID string, -) error { - _, err := s.db.ExecContext(ctx, - `UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, - userID, - ) - - return err -} - -// CreateUserModel inserts a new user into the database. -func (s *Database) CreateUserModel( - ctx context.Context, - id, nick, passwordHash string, -) (*models.User, error) { - now := time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO users (id, nick, password_hash) - VALUES (?, ?, ?)`, - id, nick, passwordHash, - ) - if err != nil { - return nil, err - } - - u := &models.User{ - ID: id, Nick: nick, PasswordHash: passwordHash, - CreatedAt: now, UpdatedAt: now, - } - s.Hydrate(u) - - return u, nil -} - -// CreateChannel inserts a new channel into the database. -func (s *Database) CreateChannel( - ctx context.Context, - id, name, topic, modes string, -) (*models.Channel, error) { - now := time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO channels (id, name, topic, modes) - VALUES (?, ?, ?, ?)`, - id, name, topic, modes, - ) - if err != nil { - return nil, err - } - - c := &models.Channel{ - ID: id, Name: name, Topic: topic, Modes: modes, - CreatedAt: now, UpdatedAt: now, - } - s.Hydrate(c) - - return c, nil -} - -// AddChannelMember adds a user to a channel with the given modes. -func (s *Database) AddChannelMember( - ctx context.Context, - channelID, userID, modes string, -) (*models.ChannelMember, error) { - now := time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO channel_members - (channel_id, user_id, modes) - VALUES (?, ?, ?)`, - channelID, userID, modes, - ) - if err != nil { - return nil, err - } - - cm := &models.ChannelMember{ - ChannelID: channelID, - UserID: userID, - Modes: modes, - JoinedAt: now, - } - s.Hydrate(cm) - - return cm, nil -} - -// CreateMessage inserts a new message into the database. -func (s *Database) CreateMessage( - ctx context.Context, - id, fromUserID, fromNick, target, msgType, body string, -) (*models.Message, error) { - now := time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO messages - (id, from_user_id, from_nick, target, type, body) - VALUES (?, ?, ?, ?, ?, ?)`, - id, fromUserID, fromNick, target, msgType, body, - ) - if err != nil { - return nil, err - } - - m := &models.Message{ - ID: id, - FromUserID: fromUserID, - FromNick: fromNick, - Target: target, - Type: msgType, - Body: body, - Timestamp: now, - CreatedAt: now, - } - s.Hydrate(m) - - return m, nil -} - -// QueueMessage adds a message to a user's delivery queue. -func (s *Database) QueueMessage( - ctx context.Context, - userID, messageID string, -) (*models.MessageQueueEntry, error) { - now := time.Now() - - res, err := s.db.ExecContext(ctx, - `INSERT INTO message_queue (user_id, message_id) - VALUES (?, ?)`, - userID, messageID, - ) - if err != nil { - return nil, err - } - - entryID, err := res.LastInsertId() - if err != nil { - return nil, fmt.Errorf("get last insert id: %w", err) - } - - mq := &models.MessageQueueEntry{ - ID: entryID, - UserID: userID, - MessageID: messageID, - QueuedAt: now, - } - s.Hydrate(mq) - - return mq, nil -} - -// DequeueMessages returns up to limit pending messages for a user, -// ordered by queue time (oldest first). -func (s *Database) DequeueMessages( - ctx context.Context, - userID string, - limit int, -) ([]*models.MessageQueueEntry, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT id, user_id, message_id, queued_at - FROM message_queue - WHERE user_id = ? - ORDER BY queued_at ASC - LIMIT ?`, - userID, limit, - ) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - entries := []*models.MessageQueueEntry{} - - for rows.Next() { - e := &models.MessageQueueEntry{} - s.Hydrate(e) - - err = rows.Scan(&e.ID, &e.UserID, &e.MessageID, &e.QueuedAt) - if err != nil { - return nil, err - } - - entries = append(entries, e) - } - - return entries, rows.Err() -} - -// AckMessages removes the given queue entry IDs, marking them as delivered. -func (s *Database) AckMessages( - ctx context.Context, - entryIDs []int64, -) error { - if len(entryIDs) == 0 { - return nil - } - - placeholders := make([]string, len(entryIDs)) - args := make([]any, len(entryIDs)) - - for i, id := range entryIDs { - placeholders[i] = "?" - args[i] = id - } - - query := fmt.Sprintf( //nolint:gosec // placeholders are ?, not user input - "DELETE FROM message_queue WHERE id IN (%s)", - strings.Join(placeholders, ","), - ) - - _, err := s.db.ExecContext(ctx, query, args...) - - return err -} - -// CreateAuthToken inserts a new auth token for a user. -func (s *Database) CreateAuthToken( - ctx context.Context, - token, userID string, -) (*models.AuthToken, error) { - now := time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO auth_tokens (token, user_id) - VALUES (?, ?)`, - token, userID, - ) - if err != nil { - return nil, err - } - - at := &models.AuthToken{Token: token, UserID: userID, CreatedAt: now} - s.Hydrate(at) - - return at, nil -} - -// CreateSession inserts a new session for a user. -func (s *Database) CreateSession( - ctx context.Context, - id, userID string, -) (*models.Session, error) { - now := time.Now() - - _, err := s.db.ExecContext(ctx, - `INSERT INTO sessions (id, user_id) - VALUES (?, ?)`, - id, userID, - ) - if err != nil { - return nil, err - } - - sess := &models.Session{ - ID: id, UserID: userID, - CreatedAt: now, LastActiveAt: now, - } - s.Hydrate(sess) - - return sess, nil -} - -// CreateServerLink inserts a new server link. -func (s *Database) CreateServerLink( - ctx context.Context, - id, name, url, sharedKeyHash string, - isActive bool, -) (*models.ServerLink, error) { - now := time.Now() - active := 0 - - if isActive { - active = 1 - } - - _, err := s.db.ExecContext(ctx, - `INSERT INTO server_links - (id, name, url, shared_key_hash, is_active) - VALUES (?, ?, ?, ?, ?)`, - id, name, url, sharedKeyHash, active, - ) - if err != nil { - return nil, err - } - - sl := &models.ServerLink{ - ID: id, - Name: name, - URL: url, - SharedKeyHash: sharedKeyHash, - IsActive: isActive, - CreatedAt: now, - } - s.Hydrate(sl) - - return sl, nil -} - -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" + 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) } - s.db = d - s.log.Info("database connected") + conn.SetMaxOpenConns(1) - // Item 9: Enable foreign keys on every connection - _, err = s.db.ExecContext(ctx, "PRAGMA foreign_keys = ON") + database.conn = conn + database.log.Info("database connected") + + _, err = database.conn.ExecContext( + ctx, "PRAGMA foreign_keys = ON", + ) if err != nil { return fmt.Errorf("enable foreign keys: %w", err) } - return s.runMigrations(ctx) + _, err = database.conn.ExecContext( + ctx, "PRAGMA busy_timeout = 5000", + ) + if err != nil { + return fmt.Errorf("set busy timeout: %w", err) + } + + return database.runMigrations(ctx) } type migration struct { @@ -568,51 +132,125 @@ type migration struct { sql string } -func (s *Database) runMigrations(ctx context.Context) error { - err := s.bootstrapMigrationsTable(ctx) +func (database *Database) runMigrations( + ctx context.Context, +) error { + _, err := database.conn.ExecContext(ctx, + `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`) + if err != nil { + return fmt.Errorf( + "create schema_migrations: %w", err, + ) + } + + migrations, err := database.loadMigrations() if err != nil { return err } - migrations, err := s.loadMigrations() - if err != nil { - return err + for _, mig := range migrations { + err = database.applyMigration(ctx, mig) + if err != nil { + return err + } } - err = s.applyMigrations(ctx, migrations) - if err != nil { - return err - } - - s.log.Info("database migrations complete") + database.log.Info("database migrations complete") return nil } -func (s *Database) bootstrapMigrationsTable( +func (database *Database) applyMigration( ctx context.Context, + mig migration, ) error { - _, err := s.db.ExecContext(ctx, - `CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`) + var exists int + + err := database.conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM schema_migrations + WHERE version = ?`, + mig.version, + ).Scan(&exists) if err != nil { return fmt.Errorf( - "create schema_migrations table: %w", err, + "check migration %d: %w", mig.version, err, + ) + } + + if exists > 0 { + return nil + } + + database.log.Info( + "applying migration", + "version", mig.version, + "name", mig.name, + ) + + return database.execMigration(ctx, mig) +} + +func (database *Database) execMigration( + ctx context.Context, + mig migration, +) error { + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf( + "begin tx for migration %d: %w", + mig.version, err, + ) + } + + _, err = transaction.ExecContext(ctx, mig.sql) + if err != nil { + _ = transaction.Rollback() + + return fmt.Errorf( + "apply migration %d (%s): %w", + mig.version, mig.name, err, + ) + } + + _, err = transaction.ExecContext(ctx, + `INSERT INTO schema_migrations (version) + VALUES (?)`, + mig.version, + ) + if err != nil { + _ = transaction.Rollback() + + return fmt.Errorf( + "record migration %d: %w", + mig.version, err, + ) + } + + err = transaction.Commit() + if err != nil { + return fmt.Errorf( + "commit migration %d: %w", + mig.version, err, ) } return nil } -func (s *Database) loadMigrations() ([]migration, error) { +func (database *Database) loadMigrations() ( + []migration, + error, +) { entries, err := fs.ReadDir(SchemaFiles, "schema") if err != nil { - return nil, fmt.Errorf("read schema dir: %w", err) + return nil, fmt.Errorf( + "read schema dir: %w", err, + ) } - var migrations []migration + migrations := make([]migration, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || @@ -627,17 +265,18 @@ func (s *Database) loadMigrations() ([]migration, error) { continue } - version, err := strconv.Atoi(parts[0]) - if err != nil { + version, parseErr := strconv.Atoi(parts[0]) + if parseErr != nil { continue } - content, err := SchemaFiles.ReadFile( + content, readErr := SchemaFiles.ReadFile( "schema/" + entry.Name(), ) - if err != nil { + if readErr != nil { return nil, fmt.Errorf( - "read migration %s: %w", entry.Name(), err, + "read migration %s: %w", + entry.Name(), readErr, ) } @@ -654,82 +293,3 @@ func (s *Database) loadMigrations() ([]migration, error) { return migrations, nil } - -// Item 4: Wrap each migration in a transaction -func (s *Database) applyMigrations( - ctx context.Context, - migrations []migration, -) error { - for _, m := range migrations { - var exists int - - err := s.db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", - m.version, - ).Scan(&exists) - if err != nil { - return fmt.Errorf( - "check migration %d: %w", m.version, err, - ) - } - - if exists > 0 { - continue - } - - s.log.Info( - "applying migration", - "version", m.version, "name", m.name, - ) - - err = s.executeMigration(ctx, m) - if err != nil { - return err - } - } - - return nil -} - -func (s *Database) executeMigration( - ctx context.Context, - m migration, -) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf( - "begin tx for migration %d: %w", m.version, err, - ) - } - - _, err = tx.ExecContext(ctx, m.sql) - if err != nil { - _ = tx.Rollback() - - return fmt.Errorf( - "apply migration %d (%s): %w", - m.version, m.name, err, - ) - } - - _, err = tx.ExecContext(ctx, - "INSERT INTO schema_migrations (version) VALUES (?)", - m.version, - ) - if err != nil { - _ = tx.Rollback() - - return fmt.Errorf( - "record migration %d: %w", m.version, err, - ) - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf( - "commit migration %d: %w", m.version, err, - ) - } - - return nil -} diff --git a/internal/db/db_test.go b/internal/db/db_test.go deleted file mode 100644 index b3cf841..0000000 --- a/internal/db/db_test.go +++ /dev/null @@ -1,425 +0,0 @@ -package db_test - -import ( - "context" - "fmt" - "path/filepath" - "testing" - "time" - - "git.eeqj.de/sneak/chat/internal/db" -) - -const ( - nickAlice = "alice" - nickBob = "bob" - nickCharlie = "charlie" -) - -// setupTestDB creates a fresh database in a temp directory with -// all migrations applied. -func setupTestDB(t *testing.T) *db.Database { - t.Helper() - - dir := t.TempDir() - dsn := fmt.Sprintf( - "file:%s?_journal_mode=WAL", - filepath.Join(dir, "test.db"), - ) - - d, err := db.NewTest(dsn) - if err != nil { - t.Fatalf("failed to create test database: %v", err) - } - - t.Cleanup(func() { _ = d.GetDB().Close() }) - - return d -} - -func TestCreateUser(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - id, token, err := d.CreateUser(ctx, nickAlice) - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - - if id <= 0 { - t.Errorf("expected positive id, got %d", id) - } - - if token == "" { - t.Error("expected non-empty token") - } -} - -func TestGetUserByToken(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - _, token, _ := d.CreateUser(ctx, nickAlice) - - id, nick, err := d.GetUserByToken(ctx, token) - if err != nil { - t.Fatalf("GetUserByToken: %v", err) - } - - if id <= 0 || nick != nickAlice { - t.Errorf( - "got id=%d nick=%s, want nick=%s", - id, nick, nickAlice, - ) - } -} - -func TestGetUserByNick(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - origID, _, _ := d.CreateUser(ctx, nickAlice) - - id, err := d.GetUserByNick(ctx, nickAlice) - if err != nil { - t.Fatalf("GetUserByNick: %v", err) - } - - if id != origID { - t.Errorf("got id %d, want %d", id, origID) - } -} - -func TestGetOrCreateChannel(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - id1, err := d.GetOrCreateChannel(ctx, "#general") - if err != nil { - t.Fatalf("GetOrCreateChannel: %v", err) - } - - if id1 <= 0 { - t.Errorf("expected positive id, got %d", id1) - } - - // Same channel returns same ID. - id2, err := d.GetOrCreateChannel(ctx, "#general") - if err != nil { - t.Fatalf("GetOrCreateChannel(2): %v", err) - } - - if id1 != id2 { - t.Errorf("got different ids: %d vs %d", id1, id2) - } -} - -func TestJoinAndListChannels(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid, _, _ := d.CreateUser(ctx, nickAlice) - ch1, _ := d.GetOrCreateChannel(ctx, "#alpha") - ch2, _ := d.GetOrCreateChannel(ctx, "#beta") - - _ = d.JoinChannel(ctx, ch1, uid) - _ = d.JoinChannel(ctx, ch2, uid) - - channels, err := d.ListChannels(ctx, uid) - if err != nil { - t.Fatalf("ListChannels: %v", err) - } - - if len(channels) != 2 { - t.Fatalf("expected 2 channels, got %d", len(channels)) - } -} - -func TestListChannelsEmpty(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid, _, _ := d.CreateUser(ctx, nickAlice) - - channels, err := d.ListChannels(ctx, uid) - if err != nil { - t.Fatalf("ListChannels: %v", err) - } - - if len(channels) != 0 { - t.Errorf("expected 0 channels, got %d", len(channels)) - } -} - -func TestPartChannel(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid, _, _ := d.CreateUser(ctx, nickAlice) - chID, _ := d.GetOrCreateChannel(ctx, "#general") - - _ = d.JoinChannel(ctx, chID, uid) - _ = d.PartChannel(ctx, chID, uid) - - channels, err := d.ListChannels(ctx, uid) - if err != nil { - t.Fatalf("ListChannels: %v", err) - } - - if len(channels) != 0 { - t.Errorf("expected 0 after part, got %d", len(channels)) - } -} - -func TestSendAndGetMessages(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid, _, _ := d.CreateUser(ctx, nickAlice) - chID, _ := d.GetOrCreateChannel(ctx, "#general") - _ = d.JoinChannel(ctx, chID, uid) - - _, err := d.SendMessage(ctx, chID, uid, "hello world") - if err != nil { - t.Fatalf("SendMessage: %v", err) - } - - msgs, err := d.GetMessages(ctx, chID, 0, 0) - if err != nil { - t.Fatalf("GetMessages: %v", err) - } - - if len(msgs) != 1 { - t.Fatalf("expected 1 message, got %d", len(msgs)) - } - - if msgs[0].Content != "hello world" { - t.Errorf( - "got content %q, want %q", - msgs[0].Content, "hello world", - ) - } -} - -func TestChannelMembers(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid1, _, _ := d.CreateUser(ctx, nickAlice) - uid2, _, _ := d.CreateUser(ctx, nickBob) - uid3, _, _ := d.CreateUser(ctx, nickCharlie) - chID, _ := d.GetOrCreateChannel(ctx, "#general") - - _ = d.JoinChannel(ctx, chID, uid1) - _ = d.JoinChannel(ctx, chID, uid2) - _ = d.JoinChannel(ctx, chID, uid3) - - members, err := d.ChannelMembers(ctx, chID) - if err != nil { - t.Fatalf("ChannelMembers: %v", err) - } - - if len(members) != 3 { - t.Fatalf("expected 3 members, got %d", len(members)) - } -} - -func TestChannelMembersEmpty(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - chID, _ := d.GetOrCreateChannel(ctx, "#empty") - - members, err := d.ChannelMembers(ctx, chID) - if err != nil { - t.Fatalf("ChannelMembers: %v", err) - } - - if len(members) != 0 { - t.Errorf("expected 0, got %d", len(members)) - } -} - -func TestSendDM(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid1, _, _ := d.CreateUser(ctx, nickAlice) - uid2, _, _ := d.CreateUser(ctx, nickBob) - - msgID, err := d.SendDM(ctx, uid1, uid2, "hey bob") - if err != nil { - t.Fatalf("SendDM: %v", err) - } - - if msgID <= 0 { - t.Errorf("expected positive msgID, got %d", msgID) - } - - msgs, err := d.GetDMs(ctx, uid1, uid2, 0, 0) - if err != nil { - t.Fatalf("GetDMs: %v", err) - } - - if len(msgs) != 1 { - t.Fatalf("expected 1 DM, got %d", len(msgs)) - } - - if msgs[0].Content != "hey bob" { - t.Errorf("got %q, want %q", msgs[0].Content, "hey bob") - } -} - -func TestPollMessages(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid1, _, _ := d.CreateUser(ctx, nickAlice) - uid2, _, _ := d.CreateUser(ctx, nickBob) - chID, _ := d.GetOrCreateChannel(ctx, "#general") - - _ = d.JoinChannel(ctx, chID, uid1) - _ = d.JoinChannel(ctx, chID, uid2) - - _, _ = d.SendMessage(ctx, chID, uid2, "hello") - _, _ = d.SendDM(ctx, uid2, uid1, "private") - - time.Sleep(10 * time.Millisecond) - - msgs, err := d.PollMessages(ctx, uid1, 0, 0) - if err != nil { - t.Fatalf("PollMessages: %v", err) - } - - if len(msgs) < 2 { - t.Fatalf("expected >=2 messages, got %d", len(msgs)) - } -} - -func TestChangeNick(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - _, token, _ := d.CreateUser(ctx, nickAlice) - - err := d.ChangeNick(ctx, 1, "alice2") - if err != nil { - t.Fatalf("ChangeNick: %v", err) - } - - _, nick, err := d.GetUserByToken(ctx, token) - if err != nil { - t.Fatalf("GetUserByToken: %v", err) - } - - if nick != "alice2" { - t.Errorf("got nick %q, want alice2", nick) - } -} - -func TestSetTopic(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid, _, _ := d.CreateUser(ctx, nickAlice) - _, _ = d.GetOrCreateChannel(ctx, "#general") - - err := d.SetTopic(ctx, "#general", uid, "new topic") - if err != nil { - t.Fatalf("SetTopic: %v", err) - } - - channels, err := d.ListAllChannels(ctx) - if err != nil { - t.Fatalf("ListAllChannels: %v", err) - } - - found := false - - for _, ch := range channels { - if ch.Name == "#general" && ch.Topic == "new topic" { - found = true - } - } - - if !found { - t.Error("topic was not updated") - } -} - -func TestGetMessagesBefore(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - uid, _, _ := d.CreateUser(ctx, nickAlice) - chID, _ := d.GetOrCreateChannel(ctx, "#general") - - _ = d.JoinChannel(ctx, chID, uid) - - for i := range 5 { - _, _ = d.SendMessage( - ctx, chID, uid, - fmt.Sprintf("msg%d", i), - ) - - time.Sleep(10 * time.Millisecond) - } - - msgs, err := d.GetMessagesBefore(ctx, chID, 0, 3) - if err != nil { - t.Fatalf("GetMessagesBefore: %v", err) - } - - if len(msgs) != 3 { - t.Fatalf("expected 3, got %d", len(msgs)) - } -} - -func TestListAllChannels(t *testing.T) { - t.Parallel() - - d := setupTestDB(t) - ctx := context.Background() - - _, _ = d.GetOrCreateChannel(ctx, "#alpha") - _, _ = d.GetOrCreateChannel(ctx, "#beta") - - channels, err := d.ListAllChannels(ctx) - if err != nil { - t.Fatalf("ListAllChannels: %v", err) - } - - if len(channels) != 2 { - t.Errorf("expected 2, got %d", len(channels)) - } -} diff --git a/internal/db/export_test.go b/internal/db/export_test.go new file mode 100644 index 0000000..45c0435 --- /dev/null +++ b/internal/db/export_test.go @@ -0,0 +1,60 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sync/atomic" +) + +//nolint:gochecknoglobals // test counter +var testDBCounter atomic.Int64 + +// NewTestDatabase creates an in-memory database for testing. +func NewTestDatabase() (*Database, error) { + counter := testDBCounter.Add(1) + + dsn := fmt.Sprintf( + "file:testdb%d?mode=memory"+ + "&cache=shared&_pragma=foreign_keys(1)", + counter, + ) + + conn, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open test db: %w", err) + } + + database := &Database{ //nolint:exhaustruct // test helper, params not needed + conn: conn, + log: slog.Default(), + } + + err = database.runMigrations(context.Background()) + if err != nil { + closeErr := conn.Close() + if closeErr != nil { + return nil, fmt.Errorf( + "close after migration failure: %w", + closeErr, + ) + } + + return nil, fmt.Errorf( + "run test migrations: %w", err, + ) + } + + return database, nil +} + +// Close closes the underlying database connection. +func (database *Database) Close() error { + err := database.conn.Close() + if err != nil { + return fmt.Errorf("close database: %w", err) + } + + return nil +} diff --git a/internal/db/queries.go b/internal/db/queries.go index f3567b4..e57356f 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -5,142 +5,40 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "encoding/json" "fmt" "time" + + "github.com/google/uuid" ) const ( - defaultMessageLimit = 50 - defaultPollLimit = 100 - tokenBytes = 32 + tokenBytes = 32 + defaultPollLimit = 100 + defaultHistLimit = 50 ) -func generateToken() string { - b := make([]byte, tokenBytes) - _, _ = rand.Read(b) +func generateToken() (string, error) { + buf := make([]byte, tokenBytes) - return hex.EncodeToString(b) -} - -// CreateUser registers a new user with the given nick and -// returns the user with token. -func (s *Database) CreateUser( - ctx context.Context, - nick string, -) (int64, string, error) { - token := generateToken() - now := time.Now() - - res, err := s.db.ExecContext(ctx, - "INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)", - nick, token, now, now) + _, err := rand.Read(buf) if err != nil { - return 0, "", fmt.Errorf("create user: %w", err) + return "", fmt.Errorf("generate token: %w", err) } - id, _ := res.LastInsertId() - - return id, token, nil + return hex.EncodeToString(buf), nil } -// GetUserByToken returns user id and nick for a given auth -// token. -func (s *Database) GetUserByToken( - ctx context.Context, - token string, -) (int64, string, error) { - var id int64 - - var nick string - - err := s.db.QueryRowContext( - ctx, - "SELECT id, nick FROM users WHERE token = ?", - token, - ).Scan(&id, &nick) - if err != nil { - return 0, "", err - } - - // Update last_seen - _, _ = s.db.ExecContext( - ctx, - "UPDATE users SET last_seen = ? WHERE id = ?", - time.Now(), id, - ) - - return id, nick, nil -} - -// GetUserByNick returns user id for a given nick. -func (s *Database) GetUserByNick( - ctx context.Context, - nick string, -) (int64, error) { - var id int64 - - err := s.db.QueryRowContext( - ctx, - "SELECT id FROM users WHERE nick = ?", - nick, - ).Scan(&id) - - return id, err -} - -// GetOrCreateChannel returns the channel id, creating it if -// needed. -func (s *Database) GetOrCreateChannel( - ctx context.Context, - name string, -) (int64, error) { - var id int64 - - err := s.db.QueryRowContext( - ctx, - "SELECT id FROM channels WHERE name = ?", - name, - ).Scan(&id) - if err == nil { - return id, nil - } - - now := time.Now() - - res, err := s.db.ExecContext(ctx, - "INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)", - name, now, now) - if err != nil { - return 0, fmt.Errorf("create channel: %w", err) - } - - id, _ = res.LastInsertId() - - return id, nil -} - -// JoinChannel adds a user to a channel. -func (s *Database) JoinChannel( - ctx context.Context, - channelID, userID int64, -) error { - _, err := s.db.ExecContext(ctx, - "INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)", - channelID, userID, time.Now()) - - return err -} - -// PartChannel removes a user from a channel. -func (s *Database) PartChannel( - ctx context.Context, - channelID, userID int64, -) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?", - channelID, userID) - - return err +// IRCMessage is the IRC envelope for all messages. +type IRCMessage struct { + ID string `json:"id"` + Command string `json:"command"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Body json.RawMessage `json:"body,omitempty"` + TS string `json:"ts"` + Meta json.RawMessage `json:"meta,omitempty"` + DBID int64 `json:"-"` } // ChannelInfo is a lightweight channel representation. @@ -150,46 +48,6 @@ type ChannelInfo struct { Topic string `json:"topic"` } -// ListChannels returns all channels the user has joined. -func (s *Database) ListChannels( - ctx context.Context, - userID int64, -) ([]ChannelInfo, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT c.id, c.name, c.topic FROM channels c - INNER JOIN channel_members cm ON cm.channel_id = c.id - WHERE cm.user_id = ? ORDER BY c.name`, userID) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - var channels []ChannelInfo - - for rows.Next() { - var ch ChannelInfo - - err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic) - if err != nil { - return nil, err - } - - channels = append(channels, ch) - } - - err = rows.Err() - if err != nil { - return nil, err - } - - if channels == nil { - channels = []ChannelInfo{} - } - - return channels, nil -} - // MemberInfo represents a channel member. type MemberInfo struct { ID int64 `json:"id"` @@ -197,17 +55,267 @@ type MemberInfo struct { LastSeen time.Time `json:"lastSeen"` } +// CreateUser registers a new user with the given nick. +func (database *Database) CreateUser( + ctx context.Context, + nick string, +) (int64, string, error) { + token, err := generateToken() + if err != nil { + return 0, "", err + } + + now := time.Now() + + res, err := database.conn.ExecContext(ctx, + `INSERT INTO users + (nick, token, created_at, last_seen) + VALUES (?, ?, ?, ?)`, + nick, token, now, now) + if err != nil { + return 0, "", fmt.Errorf("create user: %w", err) + } + + userID, _ := res.LastInsertId() + + return userID, token, nil +} + +// GetUserByToken returns user id and nick for a token. +func (database *Database) GetUserByToken( + ctx context.Context, + token string, +) (int64, string, error) { + var userID int64 + + var nick string + + err := database.conn.QueryRowContext( + ctx, + "SELECT id, nick FROM users WHERE token = ?", + token, + ).Scan(&userID, &nick) + if err != nil { + return 0, "", fmt.Errorf("get user by token: %w", err) + } + + _, _ = database.conn.ExecContext( + ctx, + "UPDATE users SET last_seen = ? WHERE id = ?", + time.Now(), userID, + ) + + return userID, nick, nil +} + +// GetUserByNick returns user id for a given nick. +func (database *Database) GetUserByNick( + ctx context.Context, + nick string, +) (int64, error) { + var userID int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT id FROM users WHERE nick = ?", + nick, + ).Scan(&userID) + if err != nil { + return 0, fmt.Errorf("get user by nick: %w", err) + } + + return userID, nil +} + +// GetChannelByName returns the channel ID for a name. +func (database *Database) GetChannelByName( + ctx context.Context, + name string, +) (int64, error) { + var channelID int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT id FROM channels WHERE name = ?", + name, + ).Scan(&channelID) + if err != nil { + return 0, fmt.Errorf( + "get channel by name: %w", err, + ) + } + + return channelID, nil +} + +// GetOrCreateChannel returns channel id, creating if needed. +// Uses INSERT OR IGNORE to avoid TOCTOU races. +func (database *Database) GetOrCreateChannel( + ctx context.Context, + name string, +) (int64, error) { + now := time.Now() + + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO channels + (name, created_at, updated_at) + VALUES (?, ?, ?)`, + name, now, now) + if err != nil { + return 0, fmt.Errorf("create channel: %w", err) + } + + var channelID int64 + + err = database.conn.QueryRowContext( + ctx, + "SELECT id FROM channels WHERE name = ?", + name, + ).Scan(&channelID) + if err != nil { + return 0, fmt.Errorf("get channel: %w", err) + } + + return channelID, nil +} + +// JoinChannel adds a user to a channel. +func (database *Database) JoinChannel( + ctx context.Context, + channelID, userID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO channel_members + (channel_id, user_id, joined_at) + VALUES (?, ?, ?)`, + channelID, userID, time.Now()) + if err != nil { + return fmt.Errorf("join channel: %w", err) + } + + return nil +} + +// PartChannel removes a user from a channel. +func (database *Database) PartChannel( + ctx context.Context, + channelID, userID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `DELETE FROM channel_members + WHERE channel_id = ? AND user_id = ?`, + channelID, userID) + if err != nil { + return fmt.Errorf("part channel: %w", err) + } + + return nil +} + +// DeleteChannelIfEmpty removes a channel with no members. +func (database *Database) DeleteChannelIfEmpty( + ctx context.Context, + channelID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `DELETE FROM channels WHERE id = ? + AND NOT EXISTS + (SELECT 1 FROM channel_members + WHERE channel_id = ?)`, + channelID, channelID) + if err != nil { + return fmt.Errorf( + "delete channel if empty: %w", err, + ) + } + + return nil +} + +// scanChannels scans rows into a ChannelInfo slice. +func scanChannels( + rows *sql.Rows, +) ([]ChannelInfo, error) { + defer func() { _ = rows.Close() }() + + var out []ChannelInfo + + for rows.Next() { + var chanInfo ChannelInfo + + err := rows.Scan( + &chanInfo.ID, &chanInfo.Name, &chanInfo.Topic, + ) + if err != nil { + return nil, fmt.Errorf("scan channel: %w", err) + } + + out = append(out, chanInfo) + } + + err := rows.Err() + if err != nil { + return nil, fmt.Errorf("rows error: %w", err) + } + + if out == nil { + out = []ChannelInfo{} + } + + return out, nil +} + +// ListChannels returns channels the user has joined. +func (database *Database) ListChannels( + ctx context.Context, + userID int64, +) ([]ChannelInfo, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT c.id, c.name, c.topic + FROM channels c + INNER JOIN channel_members cm + ON cm.channel_id = c.id + WHERE cm.user_id = ? + ORDER BY c.name`, userID) + if err != nil { + return nil, fmt.Errorf("list channels: %w", err) + } + + return scanChannels(rows) +} + +// ListAllChannels returns every channel. +func (database *Database) ListAllChannels( + ctx context.Context, +) ([]ChannelInfo, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT id, name, topic + FROM channels ORDER BY name`) + if err != nil { + return nil, fmt.Errorf( + "list all channels: %w", err, + ) + } + + return scanChannels(rows) +} + // ChannelMembers returns all members of a channel. -func (s *Database) ChannelMembers( +func (database *Database) ChannelMembers( ctx context.Context, channelID int64, ) ([]MemberInfo, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT u.id, u.nick, u.last_seen FROM users u - INNER JOIN channel_members cm ON cm.user_id = u.id - WHERE cm.channel_id = ? ORDER BY u.nick`, channelID) + rows, err := database.conn.QueryContext(ctx, + `SELECT u.id, u.nick, u.last_seen + FROM users u + INNER JOIN channel_members cm + ON cm.user_id = u.id + WHERE cm.channel_id = ? + ORDER BY u.nick`, channelID) if err != nil { - return nil, err + return nil, fmt.Errorf( + "query channel members: %w", err, + ) } defer func() { _ = rows.Close() }() @@ -215,19 +323,23 @@ func (s *Database) ChannelMembers( var members []MemberInfo for rows.Next() { - var m MemberInfo + var member MemberInfo - err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen) + err = rows.Scan( + &member.ID, &member.Nick, &member.LastSeen, + ) if err != nil { - return nil, err + return nil, fmt.Errorf( + "scan member: %w", err, + ) } - members = append(members, m) + members = append(members, member) } err = rows.Err() if err != nil { - return nil, err + return nil, fmt.Errorf("rows error: %w", err) } if members == nil { @@ -237,475 +349,372 @@ func (s *Database) ChannelMembers( return members, nil } -// MessageInfo represents a chat message. -type MessageInfo struct { - ID int64 `json:"id"` - Channel string `json:"channel,omitempty"` - Nick string `json:"nick"` - Content string `json:"content"` - IsDM bool `json:"isDm,omitempty"` - DMTarget string `json:"dmTarget,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -// GetMessages returns messages for a channel, optionally -// after a given ID. -func (s *Database) GetMessages( - ctx context.Context, - channelID int64, - afterID int64, - limit int, -) ([]MessageInfo, error) { - if limit <= 0 { - limit = defaultMessageLimit - } - - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, c.name, u.nick, m.content, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN channels c ON c.id = m.channel_id - WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ? - ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - var msgs []MessageInfo - - for rows.Next() { - var m MessageInfo - - err := rows.Scan( - &m.ID, &m.Channel, &m.Nick, - &m.Content, &m.CreatedAt, - ) - if err != nil { - return nil, err - } - - msgs = append(msgs, m) - } - - err = rows.Err() - if err != nil { - return nil, err - } - - if msgs == nil { - msgs = []MessageInfo{} - } - - return msgs, nil -} - -// SendMessage inserts a channel message. -func (s *Database) SendMessage( +// IsChannelMember checks if a user belongs to a channel. +func (database *Database) IsChannelMember( ctx context.Context, channelID, userID int64, - content string, -) (int64, error) { - res, err := s.db.ExecContext(ctx, - "INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)", - channelID, userID, content, time.Now()) +) (bool, error) { + var count int + + err := database.conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_members + WHERE channel_id = ? AND user_id = ?`, + channelID, userID, + ).Scan(&count) if err != nil { - return 0, err + return false, fmt.Errorf( + "check membership: %w", err, + ) } - return res.LastInsertId() + return count > 0, nil } -// SendDM inserts a direct message. -func (s *Database) SendDM( - ctx context.Context, - fromID, toID int64, - content string, -) (int64, error) { - res, err := s.db.ExecContext(ctx, - "INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)", - fromID, content, toID, time.Now()) - if err != nil { - return 0, err - } - - return res.LastInsertId() -} - -// GetDMs returns direct messages between two users after a -// given ID. -func (s *Database) GetDMs( - ctx context.Context, - userA, userB int64, - afterID int64, - limit int, -) ([]MessageInfo, error) { - if limit <= 0 { - limit = defaultMessageLimit - } - - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, u.nick, m.content, t.nick, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN users t ON t.id = m.dm_target_id - WHERE m.is_dm = 1 AND m.id > ? - AND ((m.user_id = ? AND m.dm_target_id = ?) - OR (m.user_id = ? AND m.dm_target_id = ?)) - ORDER BY m.id ASC LIMIT ?`, - afterID, userA, userB, userB, userA, limit) - if err != nil { - return nil, err - } - +// scanInt64s scans rows into an int64 slice. +func scanInt64s(rows *sql.Rows) ([]int64, error) { defer func() { _ = rows.Close() }() - var msgs []MessageInfo + var ids []int64 for rows.Next() { - var m MessageInfo + var val int64 - err := rows.Scan( - &m.ID, &m.Nick, &m.Content, - &m.DMTarget, &m.CreatedAt, - ) + err := rows.Scan(&val) if err != nil { - return nil, err + return nil, fmt.Errorf( + "scan int64: %w", err, + ) } - m.IsDM = true - - msgs = append(msgs, m) + ids = append(ids, val) } - err = rows.Err() + err := rows.Err() if err != nil { - return nil, err + return nil, fmt.Errorf("rows error: %w", err) } - if msgs == nil { - msgs = []MessageInfo{} - } - - return msgs, nil + return ids, nil } -// PollMessages returns all new messages (channel + DM) for -// a user after a given ID. -func (s *Database) PollMessages( +// GetChannelMemberIDs returns user IDs in a channel. +func (database *Database) GetChannelMemberIDs( + ctx context.Context, + channelID int64, +) ([]int64, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT user_id FROM channel_members + WHERE channel_id = ?`, channelID) + if err != nil { + return nil, fmt.Errorf( + "get channel member ids: %w", err, + ) + } + + return scanInt64s(rows) +} + +// GetUserChannelIDs returns channel IDs the user is in. +func (database *Database) GetUserChannelIDs( ctx context.Context, userID int64, - afterID int64, +) ([]int64, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT channel_id FROM channel_members + WHERE user_id = ?`, userID) + if err != nil { + return nil, fmt.Errorf( + "get user channel ids: %w", err, + ) + } + + return scanInt64s(rows) +} + +// InsertMessage stores a message and returns its DB ID. +func (database *Database) InsertMessage( + ctx context.Context, + command, from, target string, + body json.RawMessage, + meta json.RawMessage, +) (int64, string, error) { + msgUUID := uuid.New().String() + now := time.Now().UTC() + + if body == nil { + body = json.RawMessage("[]") + } + + if meta == nil { + meta = json.RawMessage("{}") + } + + res, err := database.conn.ExecContext(ctx, + `INSERT INTO messages + (uuid, command, msg_from, msg_to, + body, meta, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + msgUUID, command, from, target, + string(body), string(meta), now) + if err != nil { + return 0, "", fmt.Errorf( + "insert message: %w", err, + ) + } + + dbID, _ := res.LastInsertId() + + return dbID, msgUUID, nil +} + +// EnqueueMessage adds a message to a user's queue. +func (database *Database) EnqueueMessage( + ctx context.Context, + userID, messageID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO client_queues + (user_id, message_id, created_at) + VALUES (?, ?, ?)`, + userID, messageID, time.Now()) + if err != nil { + return fmt.Errorf("enqueue message: %w", err) + } + + return nil +} + +// PollMessages returns queued messages for a user. +func (database *Database) PollMessages( + ctx context.Context, + userID, afterQueueID int64, limit int, -) ([]MessageInfo, error) { +) ([]IRCMessage, int64, error) { if limit <= 0 { limit = defaultPollLimit } - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, - m.is_dm, COALESCE(t.nick, ''), m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - LEFT JOIN channels c ON c.id = m.channel_id - LEFT JOIN users t ON t.id = m.dm_target_id - WHERE m.id > ? AND ( - (m.is_dm = 0 AND m.channel_id IN - (SELECT channel_id FROM channel_members - WHERE user_id = ?)) - OR (m.is_dm = 1 - AND (m.user_id = ? OR m.dm_target_id = ?)) - ) - ORDER BY m.id ASC LIMIT ?`, - afterID, userID, userID, userID, limit) + rows, err := database.conn.QueryContext(ctx, + `SELECT cq.id, m.uuid, m.command, + m.msg_from, m.msg_to, + m.body, m.meta, m.created_at + FROM client_queues cq + INNER JOIN messages m + ON m.id = cq.message_id + WHERE cq.user_id = ? AND cq.id > ? + ORDER BY cq.id ASC LIMIT ?`, + userID, afterQueueID, limit) + if err != nil { + return nil, afterQueueID, fmt.Errorf( + "poll messages: %w", err, + ) + } + + msgs, lastQID, scanErr := scanMessages( + rows, afterQueueID, + ) + if scanErr != nil { + return nil, afterQueueID, scanErr + } + + return msgs, lastQID, nil +} + +// GetHistory returns message history for a target. +func (database *Database) GetHistory( + ctx context.Context, + target string, + beforeID int64, + limit int, +) ([]IRCMessage, error) { + if limit <= 0 { + limit = defaultHistLimit + } + + rows, err := database.queryHistory( + ctx, target, beforeID, limit, + ) if err != nil { return nil, err } + msgs, _, scanErr := scanMessages(rows, 0) + if scanErr != nil { + return nil, scanErr + } + + if msgs == nil { + msgs = []IRCMessage{} + } + + reverseMessages(msgs) + + return msgs, nil +} + +func (database *Database) queryHistory( + ctx context.Context, + target string, + beforeID int64, + limit int, +) (*sql.Rows, error) { + if beforeID > 0 { + rows, err := database.conn.QueryContext(ctx, + `SELECT id, uuid, command, msg_from, + msg_to, body, meta, created_at + FROM messages + WHERE msg_to = ? AND id < ? + AND command = 'PRIVMSG' + ORDER BY id DESC LIMIT ?`, + target, beforeID, limit) + if err != nil { + return nil, fmt.Errorf( + "query history: %w", err, + ) + } + + return rows, nil + } + + rows, err := database.conn.QueryContext(ctx, + `SELECT id, uuid, command, msg_from, + msg_to, body, meta, created_at + FROM messages + WHERE msg_to = ? + AND command = 'PRIVMSG' + ORDER BY id DESC LIMIT ?`, + target, limit) + if err != nil { + return nil, fmt.Errorf("query history: %w", err) + } + + return rows, nil +} + +func scanMessages( + rows *sql.Rows, + fallbackQID int64, +) ([]IRCMessage, int64, error) { defer func() { _ = rows.Close() }() - msgs := make([]MessageInfo, 0) + var msgs []IRCMessage + + lastQID := fallbackQID for rows.Next() { var ( - m MessageInfo - isDM int + msg IRCMessage + qID int64 + body, meta string + createdAt time.Time ) err := rows.Scan( - &m.ID, &m.Channel, &m.Nick, &m.Content, - &isDM, &m.DMTarget, &m.CreatedAt, + &qID, &msg.ID, &msg.Command, + &msg.From, &msg.To, + &body, &meta, &createdAt, ) if err != nil { - return nil, err + return nil, fallbackQID, fmt.Errorf( + "scan message: %w", err, + ) } - m.IsDM = isDM == 1 - msgs = append(msgs, m) - } + msg.Body = json.RawMessage(body) + msg.Meta = json.RawMessage(meta) + msg.TS = createdAt.Format(time.RFC3339Nano) + msg.DBID = qID + lastQID = qID - err = rows.Err() - if err != nil { - return nil, err - } - - return msgs, nil -} - -func scanChannelMessages( - rows *sql.Rows, -) ([]MessageInfo, error) { - var msgs []MessageInfo - - for rows.Next() { - var m MessageInfo - - err := rows.Scan( - &m.ID, &m.Channel, &m.Nick, - &m.Content, &m.CreatedAt, - ) - if err != nil { - return nil, err - } - - msgs = append(msgs, m) + msgs = append(msgs, msg) } err := rows.Err() if err != nil { - return nil, err - } - - if msgs == nil { - msgs = []MessageInfo{} - } - - return msgs, nil -} - -func scanDMMessages( - rows *sql.Rows, -) ([]MessageInfo, error) { - var msgs []MessageInfo - - for rows.Next() { - var m MessageInfo - - err := rows.Scan( - &m.ID, &m.Nick, &m.Content, - &m.DMTarget, &m.CreatedAt, + return nil, fallbackQID, fmt.Errorf( + "rows error: %w", err, ) - if err != nil { - return nil, err - } - - m.IsDM = true - - msgs = append(msgs, m) - } - - err := rows.Err() - if err != nil { - return nil, err } if msgs == nil { - msgs = []MessageInfo{} + msgs = []IRCMessage{} } - return msgs, nil + return msgs, lastQID, nil } -func reverseMessages(msgs []MessageInfo) { +func reverseMessages(msgs []IRCMessage) { for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { msgs[i], msgs[j] = msgs[j], msgs[i] } } -// GetMessagesBefore returns channel messages before a given -// ID (for history scrollback). -func (s *Database) GetMessagesBefore( - ctx context.Context, - channelID int64, - beforeID int64, - limit int, -) ([]MessageInfo, error) { - if limit <= 0 { - limit = defaultMessageLimit - } - - var query string - - var args []any - - if beforeID > 0 { - query = `SELECT m.id, c.name, u.nick, m.content, - m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN channels c ON c.id = m.channel_id - WHERE m.channel_id = ? AND m.is_dm = 0 - AND m.id < ? - ORDER BY m.id DESC LIMIT ?` - args = []any{channelID, beforeID, limit} - } else { - query = `SELECT m.id, c.name, u.nick, m.content, - m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN channels c ON c.id = m.channel_id - WHERE m.channel_id = ? AND m.is_dm = 0 - ORDER BY m.id DESC LIMIT ?` - args = []any{channelID, limit} - } - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - msgs, scanErr := scanChannelMessages(rows) - if scanErr != nil { - return nil, scanErr - } - - // Reverse to ascending order. - reverseMessages(msgs) - - return msgs, nil -} - -// GetDMsBefore returns DMs between two users before a given -// ID (for history scrollback). -func (s *Database) GetDMsBefore( - ctx context.Context, - userA, userB int64, - beforeID int64, - limit int, -) ([]MessageInfo, error) { - if limit <= 0 { - limit = defaultMessageLimit - } - - var query string - - var args []any - - if beforeID > 0 { - query = `SELECT m.id, u.nick, m.content, t.nick, - m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN users t ON t.id = m.dm_target_id - WHERE m.is_dm = 1 AND m.id < ? - AND ((m.user_id = ? AND m.dm_target_id = ?) - OR (m.user_id = ? AND m.dm_target_id = ?)) - ORDER BY m.id DESC LIMIT ?` - args = []any{ - beforeID, userA, userB, userB, userA, limit, - } - } else { - query = `SELECT m.id, u.nick, m.content, t.nick, - m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN users t ON t.id = m.dm_target_id - WHERE m.is_dm = 1 - AND ((m.user_id = ? AND m.dm_target_id = ?) - OR (m.user_id = ? AND m.dm_target_id = ?)) - ORDER BY m.id DESC LIMIT ?` - args = []any{userA, userB, userB, userA, limit} - } - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - msgs, scanErr := scanDMMessages(rows) - if scanErr != nil { - return nil, scanErr - } - - // Reverse to ascending order. - reverseMessages(msgs) - - return msgs, nil -} - // ChangeNick updates a user's nickname. -func (s *Database) ChangeNick( +func (database *Database) ChangeNick( ctx context.Context, userID int64, newNick string, ) error { - _, err := s.db.ExecContext(ctx, + _, err := database.conn.ExecContext(ctx, "UPDATE users SET nick = ? WHERE id = ?", newNick, userID) + if err != nil { + return fmt.Errorf("change nick: %w", err) + } - return err + return nil } // SetTopic sets the topic for a channel. -func (s *Database) SetTopic( +func (database *Database) SetTopic( ctx context.Context, - channelName string, - _ int64, - topic string, + channelName, topic string, ) error { - _, err := s.db.ExecContext(ctx, - "UPDATE channels SET topic = ? WHERE name = ?", - topic, channelName) + _, err := database.conn.ExecContext(ctx, + `UPDATE channels SET topic = ?, + updated_at = ? WHERE name = ?`, + topic, time.Now(), channelName) + if err != nil { + return fmt.Errorf("set topic: %w", err) + } - return err + return nil } -// GetServerName returns the server name (unused, config -// provides this). -func (s *Database) GetServerName() string { - return "" -} - -// ListAllChannels returns all channels. -func (s *Database) ListAllChannels( +// DeleteUser removes a user and all their data. +func (database *Database) DeleteUser( ctx context.Context, -) ([]ChannelInfo, error) { - rows, err := s.db.QueryContext(ctx, - "SELECT id, name, topic FROM channels ORDER BY name") + userID int64, +) error { + _, err := database.conn.ExecContext( + ctx, + "DELETE FROM users WHERE id = ?", + userID, + ) if err != nil { - return nil, err + return fmt.Errorf("delete user: %w", err) } - defer func() { _ = rows.Close() }() - - var channels []ChannelInfo - - for rows.Next() { - var ch ChannelInfo - - err := rows.Scan( - &ch.ID, &ch.Name, &ch.Topic, - ) - if err != nil { - return nil, err - } - - channels = append(channels, ch) - } - - err = rows.Err() - if err != nil { - return nil, err - } - - if channels == nil { - channels = []ChannelInfo{} - } - - return channels, nil + return nil +} + +// GetAllChannelMembershipsForUser returns channels +// a user belongs to. +func (database *Database) GetAllChannelMembershipsForUser( + ctx context.Context, + userID int64, +) ([]ChannelInfo, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT c.id, c.name, c.topic + FROM channels c + INNER JOIN channel_members cm + ON cm.channel_id = c.id + WHERE cm.user_id = ?`, userID) + if err != nil { + return nil, fmt.Errorf( + "get memberships: %w", err, + ) + } + + return scanChannels(rows) } diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go new file mode 100644 index 0000000..a83a951 --- /dev/null +++ b/internal/db/queries_test.go @@ -0,0 +1,569 @@ +package db_test + +import ( + "encoding/json" + "testing" + + "git.eeqj.de/sneak/chat/internal/db" + + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *db.Database { + t.Helper() + + database, err := db.NewTestDatabase() + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + closeErr := database.Close() + if closeErr != nil { + t.Logf("close db: %v", closeErr) + } + }) + + return database +} + +func TestCreateUser(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + id, token, err := database.CreateUser(ctx, "alice") + if err != nil { + t.Fatal(err) + } + + if id == 0 || token == "" { + t.Fatal("expected valid id and token") + } + + _, _, err = database.CreateUser(ctx, "alice") + if err == nil { + t.Fatal("expected error for duplicate nick") + } +} + +func TestGetUserByToken(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + _, token, err := database.CreateUser(ctx, "bob") + if err != nil { + t.Fatal(err) + } + + id, nick, err := database.GetUserByToken(ctx, token) + if err != nil { + t.Fatal(err) + } + + if nick != "bob" || id == 0 { + t.Fatalf("expected bob, got %s", nick) + } + + _, _, err = database.GetUserByToken(ctx, "badtoken") + if err == nil { + t.Fatal("expected error for bad token") + } +} + +func TestGetUserByNick(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + _, _, err := database.CreateUser(ctx, "charlie") + if err != nil { + t.Fatal(err) + } + + id, err := database.GetUserByNick(ctx, "charlie") + if err != nil || id == 0 { + t.Fatal("expected to find charlie") + } + + _, err = database.GetUserByNick(ctx, "nobody") + if err == nil { + t.Fatal("expected error for unknown nick") + } +} + +func TestChannelOperations(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + chID, err := database.GetOrCreateChannel(ctx, "#test") + if err != nil || chID == 0 { + t.Fatal("expected channel id") + } + + chID2, err := database.GetOrCreateChannel(ctx, "#test") + if err != nil || chID2 != chID { + t.Fatal("expected same channel id") + } + + chID3, err := database.GetChannelByName(ctx, "#test") + if err != nil || chID3 != chID { + t.Fatal("expected same channel id") + } + + _, err = database.GetChannelByName(ctx, "#nope") + if err == nil { + t.Fatal("expected error for nonexistent channel") + } +} + +func TestJoinAndPart(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + uid, _, err := database.CreateUser(ctx, "user1") + if err != nil { + t.Fatal(err) + } + + chID, err := database.GetOrCreateChannel(ctx, "#chan") + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, chID, uid) + 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") + } + + err = database.JoinChannel(ctx, chID, uid) + if err != nil { + t.Fatal(err) + } + + err = database.PartChannel(ctx, chID, uid) + if err != nil { + t.Fatal(err) + } + + ids, _ = database.GetChannelMemberIDs(ctx, chID) + if len(ids) != 0 { + t.Fatal("expected empty channel") + } +} + +func TestDeleteChannelIfEmpty(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + chID, err := database.GetOrCreateChannel( + ctx, "#empty", + ) + if err != nil { + t.Fatal(err) + } + + uid, _, err := database.CreateUser(ctx, "temp") + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, chID, uid) + if err != nil { + t.Fatal(err) + } + + err = database.PartChannel(ctx, chID, uid) + if err != nil { + t.Fatal(err) + } + + err = database.DeleteChannelIfEmpty(ctx, chID) + if err != nil { + t.Fatal(err) + } + + _, err = database.GetChannelByName(ctx, "#empty") + if err == nil { + t.Fatal("expected channel to be deleted") + } +} + +func createUserWithChannels( + t *testing.T, + database *db.Database, + nick, ch1Name, ch2Name string, +) (int64, int64, int64) { + t.Helper() + + ctx := t.Context() + + uid, _, err := database.CreateUser(ctx, nick) + if err != nil { + t.Fatal(err) + } + + ch1, err := database.GetOrCreateChannel( + ctx, ch1Name, + ) + if err != nil { + t.Fatal(err) + } + + ch2, err := database.GetOrCreateChannel( + ctx, ch2Name, + ) + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, ch1, uid) + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, ch2, uid) + if err != nil { + t.Fatal(err) + } + + return uid, ch1, ch2 +} + +func TestListChannels(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + uid, _, _ := createUserWithChannels( + t, database, "lister", "#a", "#b", + ) + + channels, err := database.ListChannels( + t.Context(), uid, + ) + if err != nil || len(channels) != 2 { + t.Fatalf( + "expected 2 channels, got %d", + len(channels), + ) + } +} + +func TestListAllChannels(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + _, err := database.GetOrCreateChannel(ctx, "#x") + if err != nil { + t.Fatal(err) + } + + _, err = database.GetOrCreateChannel(ctx, "#y") + if err != nil { + t.Fatal(err) + } + + channels, err := database.ListAllChannels(ctx) + if err != nil || len(channels) < 2 { + t.Fatalf( + "expected >= 2 channels, got %d", + len(channels), + ) + } +} + +func TestChangeNick(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + uid, token, err := database.CreateUser(ctx, "old") + if err != nil { + t.Fatal(err) + } + + err = database.ChangeNick(ctx, uid, "new") + if err != nil { + t.Fatal(err) + } + + _, nick, err := database.GetUserByToken(ctx, token) + if err != nil { + t.Fatal(err) + } + + if nick != "new" { + t.Fatalf("expected new, got %s", nick) + } +} + +func TestSetTopic(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + _, err := database.GetOrCreateChannel( + ctx, "#topictest", + ) + if err != nil { + t.Fatal(err) + } + + err = database.SetTopic(ctx, "#topictest", "Hello") + if err != nil { + t.Fatal(err) + } + + channels, err := database.ListAllChannels(ctx) + if err != nil { + t.Fatal(err) + } + + for _, ch := range channels { + if ch.Name == "#topictest" && + ch.Topic != "Hello" { + t.Fatalf( + "expected topic Hello, got %s", + ch.Topic, + ) + } + } +} + +func TestInsertMessage(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + body := json.RawMessage(`["hello"]`) + + dbID, msgUUID, err := database.InsertMessage( + ctx, "PRIVMSG", "poller", "#test", body, nil, + ) + if err != nil { + t.Fatal(err) + } + + if dbID == 0 || msgUUID == "" { + t.Fatal("expected valid id and uuid") + } +} + +func TestPollMessages(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, _, err := database.InsertMessage( + ctx, "PRIVMSG", "poller", "#test", body, nil, + ) + if err != nil { + t.Fatal(err) + } + + err = database.EnqueueMessage(ctx, uid, dbID) + if err != nil { + t.Fatal(err) + } + + const batchSize = 10 + + msgs, lastQID, err := database.PollMessages( + ctx, uid, 0, batchSize, + ) + if err != nil { + t.Fatal(err) + } + + if len(msgs) != 1 { + t.Fatalf( + "expected 1 message, got %d", len(msgs), + ) + } + + if msgs[0].Command != "PRIVMSG" { + t.Fatalf( + "expected PRIVMSG, got %s", msgs[0].Command, + ) + } + + if lastQID == 0 { + t.Fatal("expected nonzero lastQID") + } + + msgs, _, _ = database.PollMessages( + ctx, uid, lastQID, batchSize, + ) + + if len(msgs) != 0 { + t.Fatalf( + "expected 0 messages, got %d", len(msgs), + ) + } +} + +func TestGetHistory(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + const msgCount = 10 + + for range msgCount { + _, _, err := database.InsertMessage( + ctx, "PRIVMSG", "user", "#hist", + json.RawMessage(`["msg"]`), nil, + ) + if err != nil { + t.Fatal(err) + } + } + + const histLimit = 5 + + msgs, err := database.GetHistory( + ctx, "#hist", 0, histLimit, + ) + if err != nil { + t.Fatal(err) + } + + if len(msgs) != histLimit { + t.Fatalf("expected %d, got %d", + histLimit, len(msgs)) + } + + if msgs[0].DBID > msgs[histLimit-1].DBID { + t.Fatal("expected ascending order") + } +} + +func TestDeleteUser(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + uid, _, err := database.CreateUser(ctx, "deleteme") + if err != nil { + t.Fatal(err) + } + + chID, err := database.GetOrCreateChannel( + ctx, "#delchan", + ) + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, chID, uid) + if err != nil { + t.Fatal(err) + } + + err = database.DeleteUser(ctx, uid) + if err != nil { + t.Fatal(err) + } + + _, err = database.GetUserByNick(ctx, "deleteme") + if err == nil { + t.Fatal("user should be deleted") + } + + ids, _ := database.GetChannelMemberIDs(ctx, chID) + if len(ids) != 0 { + t.Fatal("expected no members after deletion") + } +} + +func TestChannelMembers(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + uid1, _, err := database.CreateUser(ctx, "m1") + if err != nil { + t.Fatal(err) + } + + uid2, _, err := database.CreateUser(ctx, "m2") + if err != nil { + t.Fatal(err) + } + + chID, err := database.GetOrCreateChannel( + ctx, "#members", + ) + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, chID, uid1) + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, chID, uid2) + if err != nil { + t.Fatal(err) + } + + members, err := database.ChannelMembers(ctx, chID) + if err != nil || len(members) != 2 { + t.Fatalf( + "expected 2 members, got %d", + len(members), + ) + } +} + +func TestGetAllChannelMembershipsForUser(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + uid, _, _ := createUserWithChannels( + t, database, "multi", "#m1", "#m2", + ) + + channels, err := + database.GetAllChannelMembershipsForUser( + t.Context(), uid, + ) + if err != nil || len(channels) != 2 { + t.Fatalf( + "expected 2 channels, got %d", + len(channels), + ) + } +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index 3741469..8434f78 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -1,4 +1,54 @@ -CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +-- 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nick TEXT NOT NULL UNIQUE, + 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); + +-- Channels +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + topic TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Channel members +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, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(channel_id, user_id) +); + +-- Messages: IRC envelope format +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + command TEXT NOT NULL DEFAULT 'PRIVMSG', + msg_from TEXT NOT NULL DEFAULT '', + msg_to TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '[]', + meta TEXT NOT NULL DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); +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, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, message_id) +); +CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id); diff --git a/internal/db/schema/002_schema.sql b/internal/db/schema/002_schema.sql deleted file mode 100644 index 58dcb70..0000000 --- a/internal/db/schema/002_schema.sql +++ /dev/null @@ -1,89 +0,0 @@ --- All schema changes go into this file until 1.0.0 is tagged. --- There will not be migrations during the early development phase. --- After 1.0.0, new changes get their own numbered migration files. - --- Users: accounts and authentication -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, -- UUID - nick TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at DATETIME -); - --- Auth tokens: one user can have multiple active tokens (multiple devices) -CREATE TABLE IF NOT EXISTS auth_tokens ( - token TEXT PRIMARY KEY, -- random token string - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME, -- NULL = no expiry - last_used_at DATETIME -); -CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id); - --- Channels: chat rooms -CREATE TABLE IF NOT EXISTS channels ( - id TEXT PRIMARY KEY, -- UUID - name TEXT NOT NULL UNIQUE, -- #general, etc. - topic TEXT NOT NULL DEFAULT '', - modes TEXT NOT NULL DEFAULT '', -- +i, +m, +s, +t, +n - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Channel members: who is in which channel, with per-user modes -CREATE TABLE IF NOT EXISTS channel_members ( - channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - modes TEXT NOT NULL DEFAULT '', -- +o (operator), +v (voice) - joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (channel_id, user_id) -); -CREATE INDEX IF NOT EXISTS idx_channel_members_user_id ON channel_members(user_id); - --- Messages: channel and DM history (rotated per MAX_HISTORY) -CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, -- UUID - ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - from_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - from_nick TEXT NOT NULL, -- denormalized for history - target TEXT NOT NULL, -- #channel name or user UUID for DMs - type TEXT NOT NULL DEFAULT 'message', -- message, action, notice, join, part, quit, topic, mode, nick, system - body TEXT NOT NULL DEFAULT '', - meta TEXT NOT NULL DEFAULT '{}', -- JSON extensible metadata - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX IF NOT EXISTS idx_messages_target_ts ON messages(target, ts); -CREATE INDEX IF NOT EXISTS idx_messages_from_user ON messages(from_user_id); - --- Message queue: per-user pending delivery (unread messages) -CREATE TABLE IF NOT EXISTS message_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - queued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, message_id) -); -CREATE INDEX IF NOT EXISTS idx_message_queue_user_id ON message_queue(user_id, queued_at); - --- Sessions: server-held session state -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, -- UUID - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME -- idle timeout -); -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); - --- Server links: federation peer configuration -CREATE TABLE IF NOT EXISTS server_links ( - id TEXT PRIMARY KEY, -- UUID - name TEXT NOT NULL UNIQUE, -- human-readable peer name - url TEXT NOT NULL, -- base URL of peer server - shared_key_hash TEXT NOT NULL, -- hashed shared secret - is_active INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at DATETIME -); diff --git a/internal/db/schema/003_users.sql b/internal/db/schema/003_users.sql deleted file mode 100644 index a89aad8..0000000 --- a/internal/db/schema/003_users.sql +++ /dev/null @@ -1,53 +0,0 @@ --- Migration 003: Replace UUID-based tables with simple integer-keyed --- tables for the HTTP API. Drops the 002 tables and recreates them. - -PRAGMA foreign_keys = OFF; - -DROP TABLE IF EXISTS message_queue; -DROP TABLE IF EXISTS sessions; -DROP TABLE IF EXISTS server_links; -DROP TABLE IF EXISTS messages; -DROP TABLE IF EXISTS channel_members; -DROP TABLE IF EXISTS auth_tokens; -DROP TABLE IF EXISTS channels; -DROP TABLE IF EXISTS users; - -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nick TEXT NOT NULL UNIQUE, - token TEXT NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_seen DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - topic TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE 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, - joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(channel_id, user_id) -); - -CREATE TABLE messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - is_dm INTEGER NOT NULL DEFAULT 0, - dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_messages_channel ON messages(channel_id, created_at); -CREATE INDEX idx_messages_dm ON messages(user_id, dm_target_id, created_at); -CREATE INDEX idx_users_token ON users(token); - -PRAGMA foreign_keys = ON; diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 764a0fe..3c9a428 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1,45 +1,75 @@ package handlers import ( - "database/sql" "encoding/json" + "fmt" "net/http" + "regexp" "strconv" "strings" + "time" - "git.eeqj.de/sneak/chat/internal/db" "github.com/go-chi/chi" ) -const ( - maxNickLen = 32 - defaultHistory = 50 +var validNickRe = regexp.MustCompile( + `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, ) -// authUser extracts the user from the Authorization header -// (Bearer token). -func (s *Handlers) authUser( - r *http.Request, +var validChannelRe = regexp.MustCompile( + `^#[a-zA-Z0-9_\-]{1,63}$`, +) + +const ( + maxLongPollTimeout = 30 + pollMessageLimit = 100 + defaultMaxBodySize = 4096 + defaultHistLimit = 50 + maxHistLimit = 500 + cmdPrivmsg = "PRIVMSG" +) + +func (hdlr *Handlers) maxBodySize() int64 { + if hdlr.params.Config.MaxMessageSize > 0 { + return int64(hdlr.params.Config.MaxMessageSize) + } + + return defaultMaxBodySize +} + +// authUser extracts the user from the Authorization header. +func (hdlr *Handlers) authUser( + request *http.Request, ) (int64, string, error) { - auth := r.Header.Get("Authorization") + auth := request.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { - return 0, "", sql.ErrNoRows + return 0, "", errUnauthorized } token := strings.TrimPrefix(auth, "Bearer ") + if token == "" { + return 0, "", errUnauthorized + } - return s.params.Database.GetUserByToken(r.Context(), token) + uid, nick, err := hdlr.params.Database.GetUserByToken( + request.Context(), token, + ) + if err != nil { + return 0, "", fmt.Errorf("auth: %w", err) + } + + return uid, nick, nil } -func (s *Handlers) requireAuth( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) requireAuth( + writer http.ResponseWriter, + request *http.Request, ) (int64, string, bool) { - uid, nick, err := s.authUser(r) + uid, nick, err := hdlr.authUser(request) if err != nil { - s.respondJSON( - w, r, - map[string]string{"error": "unauthorized"}, + hdlr.respondError( + writer, request, + "unauthorized", http.StatusUnauthorized, ) @@ -49,534 +79,807 @@ func (s *Handlers) requireAuth( return uid, nick, true } -func (s *Handlers) respondError( - w http.ResponseWriter, - r *http.Request, - msg string, - code int, -) { - s.respondJSON(w, r, map[string]string{"error": msg}, code) -} +// fanOut stores a message and enqueues it to all specified +// user IDs, then notifies them. +func (hdlr *Handlers) fanOut( + request *http.Request, + command, from, target string, + body json.RawMessage, + userIDs []int64, +) (string, error) { + dbID, msgUUID, err := hdlr.params.Database.InsertMessage( + request.Context(), command, from, target, body, nil, + ) + if err != nil { + return "", fmt.Errorf("insert message: %w", err) + } -func (s *Handlers) internalError( - w http.ResponseWriter, - r *http.Request, - msg string, - err error, -) { - s.log.Error(msg, "error", err) - s.respondError(w, r, "internal error", http.StatusInternalServerError) -} - -// bodyLines extracts body as string lines from a request body -// field. -func bodyLines(body any) []string { - switch v := body.(type) { - case []any: - lines := make([]string, 0, len(v)) - - for _, item := range v { - if s, ok := item.(string); ok { - lines = append(lines, s) - } + for _, uid := range userIDs { + enqErr := hdlr.params.Database.EnqueueMessage( + request.Context(), uid, dbID, + ) + if enqErr != nil { + hdlr.log.Error("enqueue failed", + "error", enqErr, "user_id", uid) } - return lines - case []string: - return v - default: - return nil + hdlr.broker.Notify(uid) } + + return msgUUID, nil } -// HandleCreateSession creates a new user session and returns -// the auth token. -func (s *Handlers) HandleCreateSession() http.HandlerFunc { - type request struct { +// fanOutSilent calls fanOut and discards the UUID. +func (hdlr *Handlers) fanOutSilent( + request *http.Request, + command, from, target string, + body json.RawMessage, + userIDs []int64, +) error { + _, err := hdlr.fanOut( + request, command, from, target, body, userIDs, + ) + + return err +} + +// HandleCreateSession creates a new user session. +func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc { + type createRequest struct { Nick string `json:"nick"` } - type response struct { + type createResponse struct { ID int64 `json:"id"` Nick string `json:"nick"` Token string `json:"token"` } - return func(w http.ResponseWriter, r *http.Request) { - var req request + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + request.Body = http.MaxBytesReader( + writer, request.Body, hdlr.maxBodySize(), + ) - err := json.NewDecoder(r.Body).Decode(&req) + var payload createRequest + + err := json.NewDecoder(request.Body).Decode(&payload) if err != nil { - s.respondError( - w, r, "invalid request", + hdlr.respondError( + writer, request, + "invalid request body", http.StatusBadRequest, ) return } - req.Nick = strings.TrimSpace(req.Nick) + payload.Nick = strings.TrimSpace(payload.Nick) - if req.Nick == "" || len(req.Nick) > maxNickLen { - s.respondError( - w, r, "nick must be 1-32 characters", + if !validNickRe.MatchString(payload.Nick) { + hdlr.respondError( + writer, request, + "invalid nick format", http.StatusBadRequest, ) return } - id, token, err := s.params.Database.CreateUser( - r.Context(), req.Nick, + userID, token, err := hdlr.params.Database.CreateUser( + request.Context(), payload.Nick, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { - s.respondError( - w, r, "nick already taken", + hdlr.respondError( + writer, request, + "nick already taken", http.StatusConflict, ) return } - s.internalError(w, r, "create user failed", err) + hdlr.log.Error( + "create user failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) return } - s.respondJSON( - w, r, - &response{ID: id, Nick: req.Nick, Token: token}, + hdlr.respondJSON( + writer, request, + &createResponse{ + ID: userID, + Nick: payload.Nick, + Token: token, + }, http.StatusCreated, ) } } -// HandleState returns the current user's info and joined -// channels. -func (s *Handlers) HandleState() http.HandlerFunc { - type response struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - Channels []db.ChannelInfo `json:"channels"` - } - - return func(w http.ResponseWriter, r *http.Request) { - uid, nick, ok := s.requireAuth(w, r) +// HandleState returns the current user's info and channels. +func (hdlr *Handlers) HandleState() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + uid, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } - channels, err := s.params.Database.ListChannels( - r.Context(), uid, + channels, err := hdlr.params.Database.ListChannels( + request.Context(), uid, ) if err != nil { - s.internalError( - w, r, "list channels failed", err, + hdlr.log.Error( + "list channels failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, ) return } - s.respondJSON( - w, r, - &response{ - ID: uid, Nick: nick, - Channels: channels, - }, - http.StatusOK, - ) + hdlr.respondJSON(writer, request, map[string]any{ + "id": uid, + "nick": nick, + "channels": channels, + }, http.StatusOK) } } // HandleListAllChannels returns all channels on the server. -func (s *Handlers) HandleListAllChannels() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - _, _, ok := s.requireAuth(w, r) +func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + _, _, ok := hdlr.requireAuth(writer, request) if !ok { return } - channels, err := s.params.Database.ListAllChannels( - r.Context(), + channels, err := hdlr.params.Database.ListAllChannels( + request.Context(), ) if err != nil { - s.internalError( - w, r, "list all channels failed", err, + hdlr.log.Error( + "list all channels failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, ) return } - s.respondJSON(w, r, channels, http.StatusOK) + hdlr.respondJSON( + writer, request, channels, http.StatusOK, + ) } } // HandleChannelMembers returns members of a channel. -func (s *Handlers) HandleChannelMembers() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - _, _, ok := s.requireAuth(w, r) +func (hdlr *Handlers) HandleChannelMembers() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + _, _, ok := hdlr.requireAuth(writer, request) if !ok { return } - name := "#" + chi.URLParam(r, "channel") + name := "#" + chi.URLParam(request, "channel") - var chID int64 - - err := s.params.Database.GetDB().QueryRowContext( //nolint:gosec // parameterized query - r.Context(), - "SELECT id FROM channels WHERE name = ?", - name, - ).Scan(&chID) + chID, err := hdlr.params.Database.GetChannelByName( + request.Context(), name, + ) if err != nil { - s.respondError( - w, r, "channel not found", + hdlr.respondError( + writer, request, + "channel not found", http.StatusNotFound, ) return } - members, err := s.params.Database.ChannelMembers( - r.Context(), chID, + members, err := hdlr.params.Database.ChannelMembers( + request.Context(), chID, ) if err != nil { - s.internalError( - w, r, "channel members failed", err, + hdlr.log.Error( + "channel members failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, ) return } - s.respondJSON(w, r, members, http.StatusOK) + hdlr.respondJSON( + writer, request, members, http.StatusOK, + ) } } -// HandleGetMessages returns all new messages (channel + DM) -// for the user via long-polling. -func (s *Handlers) HandleGetMessages() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) +// HandleGetMessages returns messages via long-polling. +func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + uid, _, ok := hdlr.requireAuth(writer, request) if !ok { return } afterID, _ := strconv.ParseInt( - r.URL.Query().Get("after"), 10, 64, + request.URL.Query().Get("after"), 10, 64, ) - limit, _ := strconv.Atoi( - r.URL.Query().Get("limit"), + timeout, _ := strconv.Atoi( + request.URL.Query().Get("timeout"), ) + if timeout < 0 { + timeout = 0 + } - msgs, err := s.params.Database.PollMessages( - r.Context(), uid, afterID, limit, + if timeout > maxLongPollTimeout { + timeout = maxLongPollTimeout + } + + msgs, lastQID, err := hdlr.params.Database.PollMessages( + request.Context(), uid, + afterID, pollMessageLimit, ) if err != nil { - s.internalError( - w, r, "get messages failed", err, + hdlr.log.Error( + "poll messages failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, ) return } - s.respondJSON(w, r, msgs, http.StatusOK) + if len(msgs) > 0 || timeout == 0 { + hdlr.respondJSON(writer, request, map[string]any{ + "messages": msgs, + "last_id": lastQID, + }, http.StatusOK) + + return + } + + hdlr.longPoll(writer, request, uid, afterID, timeout) } } -type sendRequest struct { - Command string `json:"command"` - To string `json:"to"` - Params []string `json:"params,omitempty"` - Body any `json:"body,omitempty"` +func (hdlr *Handlers) longPoll( + writer http.ResponseWriter, + request *http.Request, + uid, afterID int64, + timeout int, +) { + waitCh := hdlr.broker.Wait(uid) + + timer := time.NewTimer( + time.Duration(timeout) * time.Second, + ) + + defer timer.Stop() + + select { + case <-waitCh: + case <-timer.C: + case <-request.Context().Done(): + hdlr.broker.Remove(uid, waitCh) + + return + } + + hdlr.broker.Remove(uid, waitCh) + + msgs, lastQID, err := hdlr.params.Database.PollMessages( + request.Context(), uid, + afterID, pollMessageLimit, + ) + if err != nil { + hdlr.log.Error( + "poll messages failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.respondJSON(writer, request, map[string]any{ + "messages": msgs, + "last_id": lastQID, + }, http.StatusOK) } -// HandleSendCommand handles all C2S commands via POST -// /messages. -func (s *Handlers) HandleSendCommand() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, nick, ok := s.requireAuth(w, r) +// HandleSendCommand handles all C2S commands. +func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { + type commandRequest struct { + Command string `json:"command"` + To string `json:"to"` + Body json.RawMessage `json:"body,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` + } + + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + request.Body = http.MaxBytesReader( + writer, request.Body, hdlr.maxBodySize(), + ) + + uid, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } - var req sendRequest + var payload commandRequest - err := json.NewDecoder(r.Body).Decode(&req) + err := json.NewDecoder(request.Body).Decode(&payload) if err != nil { - s.respondError( - w, r, "invalid request", + hdlr.respondError( + writer, request, + "invalid request body", http.StatusBadRequest, ) return } - req.Command = strings.ToUpper( - strings.TrimSpace(req.Command), + payload.Command = strings.ToUpper( + strings.TrimSpace(payload.Command), ) - req.To = strings.TrimSpace(req.To) + payload.To = strings.TrimSpace(payload.To) - s.dispatchCommand(w, r, uid, nick, &req) + if payload.Command == "" { + hdlr.respondError( + writer, request, + "command required", + http.StatusBadRequest, + ) + + return + } + + bodyLines := func() []string { + if payload.Body == nil { + return nil + } + + var lines []string + + decErr := json.Unmarshal(payload.Body, &lines) + if decErr != nil { + return nil + } + + return lines + } + + hdlr.dispatchCommand( + writer, request, uid, nick, + payload.Command, payload.To, + payload.Body, bodyLines, + ) } } -func (s *Handlers) dispatchCommand( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) dispatchCommand( + writer http.ResponseWriter, + request *http.Request, uid int64, - nick string, - req *sendRequest, + nick, command, target string, + body json.RawMessage, + bodyLines func() []string, ) { - switch req.Command { - case "PRIVMSG", "NOTICE": - s.handlePrivmsg(w, r, uid, req) + switch command { + case cmdPrivmsg, "NOTICE": + hdlr.handlePrivmsg( + writer, request, uid, nick, + command, target, body, bodyLines, + ) case "JOIN": - s.handleJoin(w, r, uid, req) + hdlr.handleJoin( + writer, request, uid, nick, target, + ) case "PART": - s.handlePart(w, r, uid, req) + hdlr.handlePart( + writer, request, uid, nick, target, body, + ) case "NICK": - s.handleNick(w, r, uid, req) + hdlr.handleNick( + writer, request, uid, nick, bodyLines, + ) case "TOPIC": - s.handleTopic(w, r, uid, req) + hdlr.handleTopic( + writer, request, nick, target, body, bodyLines, + ) + case "QUIT": + hdlr.handleQuit( + writer, request, uid, nick, body, + ) case "PING": - s.respondJSON( - w, r, + hdlr.respondJSON(writer, request, map[string]string{ "command": "PONG", - "from": s.params.Config.ServerName, + "from": hdlr.params.Config.ServerName, }, - http.StatusOK, - ) + http.StatusOK) default: - _ = nick - - s.respondError( - w, r, - "unknown command: "+req.Command, + hdlr.respondError( + writer, request, + "unknown command: "+command, http.StatusBadRequest, ) } } -func (s *Handlers) handlePrivmsg( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) handlePrivmsg( + writer http.ResponseWriter, + request *http.Request, uid int64, - req *sendRequest, + nick, command, target string, + body json.RawMessage, + bodyLines func() []string, ) { - if req.To == "" { - s.respondError( - w, r, "to field required", + if target == "" { + hdlr.respondError( + writer, request, + "to field required", http.StatusBadRequest, ) return } - lines := bodyLines(req.Body) + lines := bodyLines() if len(lines) == 0 { - s.respondError( - w, r, "body required", http.StatusBadRequest, + hdlr.respondError( + writer, request, + "body required", + http.StatusBadRequest, ) return } - content := strings.Join(lines, "\n") + if strings.HasPrefix(target, "#") { + hdlr.handleChannelMsg( + writer, request, uid, nick, + command, target, body, + ) - if strings.HasPrefix(req.To, "#") { - s.sendChannelMsg(w, r, uid, req.To, content) - } else { - s.sendDM(w, r, uid, req.To, content) + return } + + hdlr.handleDirectMsg( + writer, request, uid, nick, + command, target, body, + ) } -func (s *Handlers) sendChannelMsg( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) handleChannelMsg( + writer http.ResponseWriter, + request *http.Request, uid int64, - channel, content string, + nick, command, target string, + body json.RawMessage, ) { - var chID int64 - - err := s.params.Database.GetDB().QueryRowContext( //nolint:gosec // parameterized query - r.Context(), - "SELECT id FROM channels WHERE name = ?", - channel, - ).Scan(&chID) + chID, err := hdlr.params.Database.GetChannelByName( + request.Context(), target, + ) if err != nil { - s.respondError( - w, r, "channel not found", + hdlr.respondError( + writer, request, + "channel not found", http.StatusNotFound, ) return } - msgID, err := s.params.Database.SendMessage( - r.Context(), chID, uid, content, + isMember, err := hdlr.params.Database.IsChannelMember( + request.Context(), chID, uid, ) if err != nil { - s.internalError(w, r, "send message failed", err) + hdlr.log.Error( + "check membership failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) return } - s.respondJSON( - w, r, - map[string]any{"id": msgID, "status": "sent"}, - http.StatusCreated, + if !isMember { + hdlr.respondError( + writer, request, + "not a member of this channel", + http.StatusForbidden, + ) + + return + } + + memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( + request.Context(), chID, ) + if err != nil { + hdlr.log.Error( + "get channel members failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + msgUUID, err := hdlr.fanOut( + request, command, nick, target, body, memberIDs, + ) + if err != nil { + hdlr.log.Error("send message failed", "error", err) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.respondJSON(writer, request, + map[string]string{"id": msgUUID, "status": "sent"}, + http.StatusCreated) } -func (s *Handlers) sendDM( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) handleDirectMsg( + writer http.ResponseWriter, + request *http.Request, uid int64, - toNick, content string, + nick, command, target string, + body json.RawMessage, ) { - targetID, err := s.params.Database.GetUserByNick( - r.Context(), toNick, + targetUID, err := hdlr.params.Database.GetUserByNick( + request.Context(), target, ) if err != nil { - s.respondError( - w, r, "user not found", http.StatusNotFound, - ) - - return - } - - msgID, err := s.params.Database.SendDM( - r.Context(), uid, targetID, content, - ) - if err != nil { - s.internalError(w, r, "send dm failed", err) - - return - } - - s.respondJSON( - w, r, - map[string]any{"id": msgID, "status": "sent"}, - http.StatusCreated, - ) -} - -func (s *Handlers) handleJoin( - w http.ResponseWriter, - r *http.Request, - uid int64, - req *sendRequest, -) { - if req.To == "" { - s.respondError( - w, r, "to field required", - http.StatusBadRequest, - ) - - return - } - - channel := req.To - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - - chID, err := s.params.Database.GetOrCreateChannel( - r.Context(), channel, - ) - if err != nil { - s.internalError( - w, r, "get/create channel failed", err, - ) - - return - } - - err = s.params.Database.JoinChannel( - r.Context(), chID, uid, - ) - if err != nil { - s.internalError(w, r, "join channel failed", err) - - return - } - - s.respondJSON( - w, r, - map[string]string{ - "status": "joined", "channel": channel, - }, - http.StatusOK, - ) -} - -func (s *Handlers) handlePart( - w http.ResponseWriter, - r *http.Request, - uid int64, - req *sendRequest, -) { - if req.To == "" { - s.respondError( - w, r, "to field required", - http.StatusBadRequest, - ) - - return - } - - channel := req.To - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - - var chID int64 - - err := s.params.Database.GetDB().QueryRowContext( //nolint:gosec // parameterized query - r.Context(), - "SELECT id FROM channels WHERE name = ?", - channel, - ).Scan(&chID) - if err != nil { - s.respondError( - w, r, "channel not found", + hdlr.respondError( + writer, request, + "user not found", http.StatusNotFound, ) return } - err = s.params.Database.PartChannel( - r.Context(), chID, uid, + recipients := []int64{targetUID} + if targetUID != uid { + recipients = append(recipients, uid) + } + + msgUUID, err := hdlr.fanOut( + request, command, nick, target, body, recipients, ) if err != nil { - s.internalError(w, r, "part channel failed", err) + hdlr.log.Error("send dm failed", "error", err) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) return } - s.respondJSON( - w, r, - map[string]string{ - "status": "parted", "channel": channel, - }, - http.StatusOK, - ) + hdlr.respondJSON(writer, request, + map[string]string{"id": msgUUID, "status": "sent"}, + http.StatusCreated) } -func (s *Handlers) handleNick( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) handleJoin( + writer http.ResponseWriter, + request *http.Request, uid int64, - req *sendRequest, + nick, target string, ) { - lines := bodyLines(req.Body) + if target == "" { + hdlr.respondError( + writer, request, + "to field required", + http.StatusBadRequest, + ) + + return + } + + channel := target + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + if !validChannelRe.MatchString(channel) { + hdlr.respondError( + writer, request, + "invalid channel name", + http.StatusBadRequest, + ) + + return + } + + chID, err := hdlr.params.Database.GetOrCreateChannel( + request.Context(), channel, + ) + if err != nil { + hdlr.log.Error( + "get/create channel failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + err = hdlr.params.Database.JoinChannel( + request.Context(), chID, uid, + ) + if err != nil { + hdlr.log.Error( + "join channel failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( + request.Context(), chID, + ) + + _ = hdlr.fanOutSilent( + request, "JOIN", nick, channel, nil, memberIDs, + ) + + hdlr.respondJSON(writer, request, + map[string]string{ + "status": "joined", + "channel": channel, + }, + http.StatusOK) +} + +func (hdlr *Handlers) handlePart( + writer http.ResponseWriter, + request *http.Request, + uid int64, + nick, target string, + body json.RawMessage, +) { + if target == "" { + hdlr.respondError( + writer, request, + "to field required", + http.StatusBadRequest, + ) + + return + } + + channel := target + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + chID, err := hdlr.params.Database.GetChannelByName( + request.Context(), channel, + ) + if err != nil { + hdlr.respondError( + writer, request, + "channel not found", + http.StatusNotFound, + ) + + return + } + + memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( + request.Context(), chID, + ) + + _ = hdlr.fanOutSilent( + request, "PART", nick, channel, body, memberIDs, + ) + + err = hdlr.params.Database.PartChannel( + request.Context(), chID, uid, + ) + if err != nil { + hdlr.log.Error( + "part channel failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + _ = hdlr.params.Database.DeleteChannelIfEmpty( + request.Context(), chID, + ) + + hdlr.respondJSON(writer, request, + map[string]string{ + "status": "parted", + "channel": channel, + }, + http.StatusOK) +} + +func (hdlr *Handlers) handleNick( + writer http.ResponseWriter, + request *http.Request, + uid int64, + nick string, + bodyLines func() []string, +) { + lines := bodyLines() if len(lines) == 0 { - s.respondError( - w, r, "body required (new nick)", + hdlr.respondError( + writer, request, + "body required (new nick)", http.StatusBadRequest, ) @@ -584,59 +887,136 @@ func (s *Handlers) handleNick( } newNick := strings.TrimSpace(lines[0]) - if newNick == "" || len(newNick) > maxNickLen { - s.respondError( - w, r, "nick must be 1-32 characters", + + if !validNickRe.MatchString(newNick) { + hdlr.respondError( + writer, request, + "invalid nick", http.StatusBadRequest, ) return } - err := s.params.Database.ChangeNick( - r.Context(), uid, newNick, + if newNick == nick { + hdlr.respondJSON(writer, request, + map[string]string{ + "status": "ok", "nick": newNick, + }, + http.StatusOK) + + return + } + + err := hdlr.params.Database.ChangeNick( + request.Context(), uid, newNick, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { - s.respondError( - w, r, "nick already in use", + hdlr.respondError( + writer, request, + "nick already in use", http.StatusConflict, ) return } - s.internalError(w, r, "change nick failed", err) + hdlr.log.Error( + "change nick failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) return } - s.respondJSON( - w, r, - map[string]string{"status": "ok", "nick": newNick}, - http.StatusOK, - ) + hdlr.broadcastNick(request, uid, nick, newNick) + + hdlr.respondJSON(writer, request, + map[string]string{ + "status": "ok", "nick": newNick, + }, + http.StatusOK) } -func (s *Handlers) handleTopic( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) broadcastNick( + request *http.Request, uid int64, - req *sendRequest, + oldNick, newNick string, ) { - if req.To == "" { - s.respondError( - w, r, "to field required", + channels, _ := hdlr.params.Database. + GetAllChannelMembershipsForUser( + request.Context(), uid, + ) + + notified := map[int64]bool{uid: true} + + nickBody, err := json.Marshal([]string{newNick}) + if err != nil { + hdlr.log.Error( + "marshal nick body", "error", err, + ) + + return + } + + dbID, _, _ := hdlr.params.Database.InsertMessage( + request.Context(), "NICK", oldNick, "", + json.RawMessage(nickBody), nil, + ) + + _ = hdlr.params.Database.EnqueueMessage( + request.Context(), uid, dbID, + ) + + hdlr.broker.Notify(uid) + + for _, chanInfo := range channels { + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs( + request.Context(), chanInfo.ID, + ) + + for _, mid := range memberIDs { + if !notified[mid] { + notified[mid] = true + + _ = hdlr.params.Database.EnqueueMessage( + request.Context(), mid, dbID, + ) + + hdlr.broker.Notify(mid) + } + } + } +} + +func (hdlr *Handlers) handleTopic( + writer http.ResponseWriter, + request *http.Request, + nick, target string, + body json.RawMessage, + bodyLines func() []string, +) { + if target == "" { + hdlr.respondError( + writer, request, + "to field required", http.StatusBadRequest, ) return } - lines := bodyLines(req.Body) + lines := bodyLines() if len(lines) == 0 { - s.respondError( - w, r, "body required (topic text)", + hdlr.respondError( + writer, request, + "body required (topic text)", http.StatusBadRequest, ) @@ -645,148 +1025,265 @@ func (s *Handlers) handleTopic( topic := strings.Join(lines, " ") - channel := req.To + channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } - err := s.params.Database.SetTopic( - r.Context(), channel, uid, topic, + err := hdlr.params.Database.SetTopic( + request.Context(), channel, topic, ) if err != nil { - s.internalError(w, r, "set topic failed", err) + hdlr.log.Error( + "set topic failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) return } - s.respondJSON( - w, r, - map[string]string{"status": "ok", "topic": topic}, - http.StatusOK, + chID, err := hdlr.params.Database.GetChannelByName( + request.Context(), channel, ) -} - -// HandleGetHistory returns message history for a specific -// target (channel or DM). -func (s *Handlers) HandleGetHistory() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } - - target := r.URL.Query().Get("target") - if target == "" { - s.respondError( - w, r, "target required", - http.StatusBadRequest, - ) - - return - } - - beforeID, _ := strconv.ParseInt( - r.URL.Query().Get("before"), 10, 64, - ) - - limit, _ := strconv.Atoi( - r.URL.Query().Get("limit"), - ) - if limit <= 0 { - limit = defaultHistory - } - - if strings.HasPrefix(target, "#") { - s.getChannelHistory( - w, r, target, beforeID, limit, - ) - } else { - s.getDMHistory( - w, r, uid, target, beforeID, limit, - ) - } - } -} - -func (s *Handlers) getChannelHistory( - w http.ResponseWriter, - r *http.Request, - target string, - beforeID int64, - limit int, -) { - var chID int64 - - err := s.params.Database.GetDB().QueryRowContext( //nolint:gosec // parameterized query - r.Context(), - "SELECT id FROM channels WHERE name = ?", - target, - ).Scan(&chID) if err != nil { - s.respondError( - w, r, "channel not found", + hdlr.respondError( + writer, request, + "channel not found", http.StatusNotFound, ) return } - msgs, err := s.params.Database.GetMessagesBefore( - r.Context(), chID, beforeID, limit, + memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( + request.Context(), chID, ) - if err != nil { - s.internalError(w, r, "get history failed", err) - return - } + _ = hdlr.fanOutSilent( + request, "TOPIC", nick, channel, body, memberIDs, + ) - s.respondJSON(w, r, msgs, http.StatusOK) + hdlr.respondJSON(writer, request, + map[string]string{ + "status": "ok", "topic": topic, + }, + http.StatusOK) } -func (s *Handlers) getDMHistory( - w http.ResponseWriter, - r *http.Request, +func (hdlr *Handlers) handleQuit( + writer http.ResponseWriter, + request *http.Request, + uid int64, + nick string, + body json.RawMessage, +) { + channels, _ := hdlr.params.Database. + GetAllChannelMembershipsForUser( + request.Context(), uid, + ) + + notified := map[int64]bool{} + + var dbID int64 + + if len(channels) > 0 { + dbID, _, _ = hdlr.params.Database.InsertMessage( + request.Context(), "QUIT", nick, "", body, nil, + ) + } + + for _, chanInfo := range channels { + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs( + request.Context(), chanInfo.ID, + ) + + for _, mid := range memberIDs { + if mid != uid && !notified[mid] { + notified[mid] = true + + _ = hdlr.params.Database.EnqueueMessage( + request.Context(), mid, dbID, + ) + + hdlr.broker.Notify(mid) + } + } + + _ = hdlr.params.Database.PartChannel( + request.Context(), chanInfo.ID, uid, + ) + + _ = hdlr.params.Database.DeleteChannelIfEmpty( + request.Context(), chanInfo.ID, + ) + } + + _ = hdlr.params.Database.DeleteUser( + request.Context(), uid, + ) + + hdlr.respondJSON(writer, request, + map[string]string{"status": "quit"}, + http.StatusOK) +} + +// HandleGetHistory returns message history for a target. +func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + uid, nick, ok := hdlr.requireAuth(writer, request) + if !ok { + return + } + + target := request.URL.Query().Get("target") + if target == "" { + hdlr.respondError( + writer, request, + "target required", + http.StatusBadRequest, + ) + + return + } + + if !hdlr.canAccessHistory( + writer, request, uid, nick, target, + ) { + return + } + + beforeID, _ := strconv.ParseInt( + request.URL.Query().Get("before"), 10, 64, + ) + + limit, _ := strconv.Atoi( + request.URL.Query().Get("limit"), + ) + if limit <= 0 || limit > maxHistLimit { + limit = defaultHistLimit + } + + msgs, err := hdlr.params.Database.GetHistory( + request.Context(), target, beforeID, limit, + ) + if err != nil { + hdlr.log.Error( + "get history failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.respondJSON( + writer, request, msgs, http.StatusOK, + ) + } +} + +// canAccessHistory verifies the user can read history +// for the given target (channel or DM participant). +func (hdlr *Handlers) canAccessHistory( + writer http.ResponseWriter, + request *http.Request, + uid int64, + nick, target string, +) bool { + if strings.HasPrefix(target, "#") { + return hdlr.canAccessChannelHistory( + writer, request, uid, target, + ) + } + + // DM history: only allow if the target is the + // requester's own nick (messages sent to them). + if target != nick { + hdlr.respondError( + writer, request, + "forbidden", + http.StatusForbidden, + ) + + return false + } + + return true +} + +func (hdlr *Handlers) canAccessChannelHistory( + writer http.ResponseWriter, + request *http.Request, uid int64, target string, - beforeID int64, - limit int, -) { - targetID, err := s.params.Database.GetUserByNick( - r.Context(), target, +) bool { + chID, err := hdlr.params.Database.GetChannelByName( + request.Context(), target, ) if err != nil { - s.respondError( - w, r, "user not found", http.StatusNotFound, + hdlr.respondError( + writer, request, + "channel not found", + http.StatusNotFound, ) - return + return false } - msgs, err := s.params.Database.GetDMsBefore( - r.Context(), uid, targetID, beforeID, limit, + isMember, err := hdlr.params.Database.IsChannelMember( + request.Context(), chID, uid, ) if err != nil { - s.internalError( - w, r, "get dm history failed", err, + hdlr.log.Error( + "check membership failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, ) - return + return false } - s.respondJSON(w, r, msgs, http.StatusOK) + if !isMember { + hdlr.respondError( + writer, request, + "not a member of this channel", + http.StatusForbidden, + ) + + return false + } + + return true } -// HandleServerInfo returns server metadata (MOTD, name). -func (s *Handlers) HandleServerInfo() http.HandlerFunc { - type response struct { +// HandleServerInfo returns server metadata. +func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { + type infoResponse struct { Name string `json:"name"` MOTD string `json:"motd"` } - return func(w http.ResponseWriter, r *http.Request) { - s.respondJSON(w, r, &response{ - Name: s.params.Config.ServerName, - MOTD: s.params.Config.MOTD, + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + hdlr.respondJSON(writer, request, &infoResponse{ + Name: hdlr.params.Config.ServerName, + MOTD: hdlr.params.Config.MOTD, }, http.StatusOK) } } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go new file mode 100644 index 0000000..fdd4e75 --- /dev/null +++ b/internal/handlers/api_test.go @@ -0,0 +1,1498 @@ +// Tests use a global viper instance for configuration, +// making parallel execution unsafe. +// +//nolint:paralleltest +package handlers_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.eeqj.de/sneak/chat/internal/config" + "git.eeqj.de/sneak/chat/internal/db" + "git.eeqj.de/sneak/chat/internal/globals" + "git.eeqj.de/sneak/chat/internal/handlers" + "git.eeqj.de/sneak/chat/internal/healthcheck" + "git.eeqj.de/sneak/chat/internal/logger" + "git.eeqj.de/sneak/chat/internal/middleware" + "git.eeqj.de/sneak/chat/internal/server" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +const ( + commandKey = "command" + bodyKey = "body" + toKey = "to" + statusKey = "status" + privmsgCmd = "PRIVMSG" + joinCmd = "JOIN" + apiMessages = "/api/v1/messages" + apiSession = "/api/v1/session" + apiState = "/api/v1/state" +) + +// testServer wraps a test HTTP server with helpers. +type testServer struct { + httpServer *httptest.Server + t *testing.T + fxApp *fxtest.App +} + +func newTestServer( + t *testing.T, +) *testServer { + t.Helper() + + dbPath := filepath.Join( + t.TempDir(), "test.db", + ) + + dbURL := "file:" + dbPath + + "?_journal_mode=WAL&_busy_timeout=5000" + + var srv *server.Server + + app := fxtest.New(t, + fx.Provide( + newTestGlobals, + logger.New, + func( + lifecycle fx.Lifecycle, + globs *globals.Globals, + log *logger.Logger, + ) (*config.Config, error) { + cfg, err := config.New( + lifecycle, config.Params{ //nolint:exhaustruct + Globals: globs, Logger: log, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "test config: %w", err, + ) + } + + cfg.DBURL = dbURL + cfg.Port = 0 + + return cfg, nil + }, + newTestDB, + newTestHealthcheck, + newTestMiddleware, + newTestHandlers, + newTestServerFx, + ), + fx.Populate(&srv), + ) + + app.RequireStart() + time.Sleep(100 * time.Millisecond) + + httpSrv := httptest.NewServer(srv) + + t.Cleanup(func() { + httpSrv.Close() + app.RequireStop() + }) + + return &testServer{ + httpServer: httpSrv, + t: t, + fxApp: app, + } +} + +func newTestGlobals() *globals.Globals { + return &globals.Globals{ + Appname: "chat-test", + Version: "test", + } +} + +func newTestDB( + lifecycle fx.Lifecycle, + log *logger.Logger, + cfg *config.Config, +) (*db.Database, error) { + database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct + Logger: log, Config: cfg, + }) + if err != nil { + return nil, fmt.Errorf("test db: %w", err) + } + + return database, nil +} + +func newTestHealthcheck( + lifecycle fx.Lifecycle, + globs *globals.Globals, + cfg *config.Config, + log *logger.Logger, + database *db.Database, +) (*healthcheck.Healthcheck, error) { + hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct + Globals: globs, + Config: cfg, + Logger: log, + Database: database, + }) + if err != nil { + return nil, fmt.Errorf("test healthcheck: %w", err) + } + + return hcheck, nil +} + +func newTestMiddleware( + lifecycle fx.Lifecycle, + log *logger.Logger, + globs *globals.Globals, + cfg *config.Config, +) (*middleware.Middleware, error) { + mware, err := middleware.New(lifecycle, middleware.Params{ //nolint:exhaustruct + Logger: log, + Globals: globs, + Config: cfg, + }) + if err != nil { + return nil, fmt.Errorf("test middleware: %w", err) + } + + return mware, nil +} + +func newTestHandlers( + lifecycle fx.Lifecycle, + log *logger.Logger, + globs *globals.Globals, + cfg *config.Config, + database *db.Database, + hcheck *healthcheck.Healthcheck, +) (*handlers.Handlers, error) { + hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct + Logger: log, + Globals: globs, + Config: cfg, + Database: database, + Healthcheck: hcheck, + }) + if err != nil { + return nil, fmt.Errorf("test handlers: %w", err) + } + + return hdlr, nil +} + +func newTestServerFx( + lifecycle fx.Lifecycle, + log *logger.Logger, + globs *globals.Globals, + cfg *config.Config, + mware *middleware.Middleware, + hdlr *handlers.Handlers, +) (*server.Server, error) { + srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct + Logger: log, + Globals: globs, + Config: cfg, + Middleware: mware, + Handlers: hdlr, + }) + if err != nil { + return nil, fmt.Errorf("test server: %w", err) + } + + return srv, nil +} + +func (tserver *testServer) url(path string) string { + return tserver.httpServer.URL + path +} + +func doRequest( + t *testing.T, + method, url string, + body io.Reader, +) (*http.Response, error) { + t.Helper() + + request, err := http.NewRequestWithContext( + t.Context(), method, url, body, + ) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + + if body != nil { + request.Header.Set( + "Content-Type", "application/json", + ) + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + + return resp, nil +} + +func doRequestAuth( + t *testing.T, + method, url, token string, + body io.Reader, +) (*http.Response, error) { + t.Helper() + + request, err := http.NewRequestWithContext( + t.Context(), method, url, body, + ) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + + if body != nil { + request.Header.Set( + "Content-Type", "application/json", + ) + } + + if token != "" { + request.Header.Set( + "Authorization", "Bearer "+token, + ) + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + + return resp, nil +} + +func (tserver *testServer) createSession( + nick string, +) string { + tserver.t.Helper() + + body, err := json.Marshal( + map[string]string{"nick": nick}, + ) + if err != nil { + tserver.t.Fatalf("marshal session: %v", err) + } + + resp, err := doRequest( + tserver.t, + http.MethodPost, + tserver.url(apiSession), + bytes.NewReader(body), + ) + if err != nil { + tserver.t.Fatalf("create session: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + tserver.t.Fatalf( + "create session: status %d: %s", + resp.StatusCode, respBody, + ) + } + + var result struct { + ID int64 `json:"id"` + Token string `json:"token"` + } + + decErr := json.NewDecoder(resp.Body).Decode(&result) + if decErr != nil { + tserver.t.Fatalf("decode session: %v", decErr) + } + + return result.Token +} + +func (tserver *testServer) sendCommand( + token string, cmd map[string]any, +) (int, map[string]any) { + tserver.t.Helper() + + body, err := json.Marshal(cmd) + if err != nil { + tserver.t.Fatalf("marshal command: %v", err) + } + + resp, err := doRequestAuth( + tserver.t, + http.MethodPost, + tserver.url(apiMessages), + token, + bytes.NewReader(body), + ) + if err != nil { + tserver.t.Fatalf("send command: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + var result map[string]any + + _ = json.NewDecoder(resp.Body).Decode(&result) + + return resp.StatusCode, result +} + +func (tserver *testServer) getState( + token string, +) (int, map[string]any) { + tserver.t.Helper() + + resp, err := doRequestAuth( + tserver.t, + http.MethodGet, + tserver.url(apiState), + token, + nil, + ) + if err != nil { + tserver.t.Fatalf("get: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + var result map[string]any + + _ = json.NewDecoder(resp.Body).Decode(&result) + + return resp.StatusCode, result +} + +func (tserver *testServer) pollMessages( + token string, afterID int64, +) ([]map[string]any, int64) { + tserver.t.Helper() + + pollURL := fmt.Sprintf( + "%s"+apiMessages+"?timeout=0&after=%d", + tserver.httpServer.URL, afterID, + ) + + resp, err := doRequestAuth( + tserver.t, + http.MethodGet, + pollURL, + token, + nil, + ) + if err != nil { + tserver.t.Fatalf("poll: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + var result struct { + Messages []map[string]any `json:"messages"` + LastID json.Number `json:"last_id"` //nolint:tagliatelle + } + + decErr := json.NewDecoder(resp.Body).Decode(&result) + if decErr != nil { + tserver.t.Fatalf("decode poll: %v", decErr) + } + + lastID, _ := result.LastID.Int64() + + return result.Messages, lastID +} + +func postSession( + t *testing.T, + tserver *testServer, + nick string, +) *http.Response { + t.Helper() + + body, err := json.Marshal( + map[string]string{"nick": nick}, + ) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + resp, err := doRequest( + t, + http.MethodPost, + tserver.url(apiSession), + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + + return resp +} + +func findMessage( + msgs []map[string]any, + command, from string, +) bool { + for _, msg := range msgs { + if msg[commandKey] == command && + msg["from"] == from { + return true + } + } + + return false +} + +// --- Tests --- + +func TestCreateSessionValid(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("alice") + + if token == "" { + t.Fatal("expected token") + } +} + +func TestCreateSessionDuplicate(t *testing.T) { + tserver := newTestServer(t) + tserver.createSession("alice") + + resp := postSession(t, tserver, "alice") + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp.StatusCode) + } +} + +func TestCreateSessionEmpty(t *testing.T) { + tserver := newTestServer(t) + + resp := postSession(t, tserver, "") + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf( + "expected 400, got %d", resp.StatusCode, + ) + } +} + +func TestCreateSessionInvalidChars(t *testing.T) { + tserver := newTestServer(t) + + resp := postSession(t, tserver, "hello world") + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf( + "expected 400, got %d", resp.StatusCode, + ) + } +} + +func TestCreateSessionNumericStart(t *testing.T) { + tserver := newTestServer(t) + + resp := postSession(t, tserver, "123abc") + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf( + "expected 400, got %d", resp.StatusCode, + ) + } +} + +func TestCreateSessionMalformed(t *testing.T) { + tserver := newTestServer(t) + + resp, err := doRequest( + t, + http.MethodPost, + tserver.url(apiSession), + strings.NewReader("{bad"), + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf( + "expected 400, got %d", resp.StatusCode, + ) + } +} + +func TestAuthNoHeader(t *testing.T) { + tserver := newTestServer(t) + + status, _ := tserver.getState("") + if status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", status) + } +} + +func TestAuthBadToken(t *testing.T) { + tserver := newTestServer(t) + + status, _ := tserver.getState( + "invalid-token-12345", + ) + if status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", status) + } +} + +func TestAuthValidToken(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("authtest") + + status, result := tserver.getState(token) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result["nick"] != "authtest" { + t.Fatalf( + "expected nick authtest, got %v", + result["nick"], + ) + } +} + +func TestJoinChannel(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("joiner") + + status, result := tserver.sendCommand( + token, + map[string]any{ + commandKey: joinCmd, toKey: "#test", + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["channel"] != "#test" { + t.Fatalf( + "expected #test, got %v", result["channel"], + ) + } +} + +func TestJoinWithoutHash(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("joiner2") + + status, result := tserver.sendCommand( + token, + map[string]any{ + commandKey: joinCmd, toKey: "other", + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["channel"] != "#other" { + t.Fatalf( + "expected #other, got %v", + result["channel"], + ) + } +} + +func TestPartChannel(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("parter") + + tserver.sendCommand( + token, + map[string]any{ + commandKey: joinCmd, toKey: "#test", + }, + ) + + status, result := tserver.sendCommand( + token, + map[string]any{ + commandKey: "PART", toKey: "#test", + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["channel"] != "#test" { + t.Fatalf( + "expected #test, got %v", result["channel"], + ) + } +} + +func TestJoinMissingTo(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("joiner3") + + status, _ := tserver.sendCommand( + token, map[string]any{commandKey: joinCmd}, + ) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestChannelMessage(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_msg") + bobToken := tserver.createSession("bob_msg") + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: "#chat", + }) + tserver.sendCommand(bobToken, map[string]any{ + commandKey: joinCmd, toKey: "#chat", + }) + + _, _ = tserver.pollMessages(aliceToken, 0) + _, bobLastID := tserver.pollMessages(bobToken, 0) + + status, result := tserver.sendCommand( + aliceToken, + map[string]any{ + commandKey: privmsgCmd, + toKey: "#chat", + bodyKey: []string{"hello world"}, + }, + ) + if status != http.StatusCreated { + t.Fatalf( + "expected 201, got %d: %v", status, result, + ) + } + + if result["id"] == nil || result["id"] == "" { + t.Fatal("expected message id") + } + + msgs, _ := tserver.pollMessages( + bobToken, bobLastID, + ) + if !findMessage(msgs, privmsgCmd, "alice_msg") { + t.Fatalf( + "bob didn't receive alice's message: %v", + msgs, + ) + } +} + +func TestMessageMissingBody(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("nobody") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#chat", + }) + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: privmsgCmd, toKey: "#chat", + }) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestMessageMissingTo(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("noto") + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: privmsgCmd, + bodyKey: []string{"hello"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestNonMemberCannotSend(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_nosend") + bobToken := tserver.createSession("bob_nosend") + + // Only bob joins the channel. + tserver.sendCommand(bobToken, map[string]any{ + commandKey: joinCmd, toKey: "#private", + }) + + // Alice tries to send without joining. + status, _ := tserver.sendCommand( + aliceToken, + map[string]any{ + commandKey: privmsgCmd, + toKey: "#private", + bodyKey: []string{"sneaky"}, + }, + ) + if status != http.StatusForbidden { + t.Fatalf("expected 403, got %d", status) + } +} + +func TestDirectMessage(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_dm") + bobToken := tserver.createSession("bob_dm") + + status, result := tserver.sendCommand( + aliceToken, + map[string]any{ + commandKey: privmsgCmd, + toKey: "bob_dm", + bodyKey: []string{"hey bob"}, + }, + ) + if status != http.StatusCreated { + t.Fatalf( + "expected 201, got %d: %v", status, result, + ) + } + + msgs, _ := tserver.pollMessages(bobToken, 0) + if !findMessage(msgs, privmsgCmd, "alice_dm") { + t.Fatal("bob didn't receive DM") + } + + aliceMsgs, _ := tserver.pollMessages(aliceToken, 0) + + found := false + + for _, msg := range aliceMsgs { + if msg[commandKey] == privmsgCmd && + msg["from"] == "alice_dm" && + msg[toKey] == "bob_dm" { + found = true + } + } + + if !found { + t.Fatal("alice didn't get DM echo") + } +} + +func TestDMToNonexistentUser(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("dmsender") + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: privmsgCmd, + toKey: "nobody", + bodyKey: []string{"hello?"}, + }) + if status != http.StatusNotFound { + t.Fatalf("expected 404, got %d", status) + } +} + +func TestNickChange(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("nick_test") + + status, result := tserver.sendCommand( + token, + map[string]any{ + commandKey: "NICK", + bodyKey: []string{"newnick"}, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["nick"] != "newnick" { + t.Fatalf( + "expected newnick, got %v", result["nick"], + ) + } +} + +func TestNickSameAsCurrent(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("same_nick") + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: "NICK", + bodyKey: []string{"same_nick"}, + }) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } +} + +func TestNickCollision(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("nickuser") + + tserver.createSession("taken_nick") + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: "NICK", + bodyKey: []string{"taken_nick"}, + }) + if status != http.StatusConflict { + t.Fatalf("expected 409, got %d", status) + } +} + +func TestNickInvalid(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("nickval") + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: "NICK", + bodyKey: []string{"bad nick!"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestNickEmptyBody(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("nicknobody") + + status, _ := tserver.sendCommand( + token, map[string]any{commandKey: "NICK"}, + ) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestTopic(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("topic_user") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#topictest", + }) + + status, result := tserver.sendCommand( + token, + map[string]any{ + commandKey: "TOPIC", + toKey: "#topictest", + bodyKey: []string{"Hello World Topic"}, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["topic"] != "Hello World Topic" { + t.Fatalf( + "expected topic, got %v", result["topic"], + ) + } +} + +func TestTopicMissingTo(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("topicnoto") + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: "TOPIC", + bodyKey: []string{"topic"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestTopicMissingBody(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("topicnobody") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#topictest", + }) + + status, _ := tserver.sendCommand(token, map[string]any{ + commandKey: "TOPIC", toKey: "#topictest", + }) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestPing(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("ping_user") + + status, result := tserver.sendCommand( + token, map[string]any{commandKey: "PING"}, + ) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "PONG" { + t.Fatalf( + "expected PONG, got %v", + result[commandKey], + ) + } +} + +func TestQuit(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("quitter") + observerToken := tserver.createSession("observer") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#quitchan", + }) + tserver.sendCommand(observerToken, map[string]any{ + commandKey: joinCmd, toKey: "#quitchan", + }) + + _, lastID := tserver.pollMessages(observerToken, 0) + + status, result := tserver.sendCommand( + token, map[string]any{commandKey: "QUIT"}, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + msgs, _ := tserver.pollMessages( + observerToken, lastID, + ) + if !findMessage(msgs, "QUIT", "quitter") { + t.Fatalf( + "observer didn't get QUIT: %v", msgs, + ) + } + + afterStatus, _ := tserver.getState(token) + if afterStatus != http.StatusUnauthorized { + t.Fatalf( + "expected 401 after quit, got %d", + afterStatus, + ) + } +} + +func TestUnknownCommand(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("cmdtest") + + status, _ := tserver.sendCommand( + token, map[string]any{commandKey: "BOGUS"}, + ) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestEmptyCommand(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("emptycmd") + + status, _ := tserver.sendCommand( + token, map[string]any{commandKey: ""}, + ) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + +func TestHistory(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("historian") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#history", + }) + + for range 5 { + tserver.sendCommand(token, map[string]any{ + commandKey: privmsgCmd, + toKey: "#history", + bodyKey: []string{"test message"}, + }) + } + + histURL := tserver.url( + "/api/v1/history?target=%23history&limit=3", + ) + + resp, err := doRequestAuth( + t, http.MethodGet, histURL, token, nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf( + "expected 200, got %d", resp.StatusCode, + ) + } + + var msgs []map[string]any + + decErr := json.NewDecoder(resp.Body).Decode(&msgs) + if decErr != nil { + t.Fatalf("decode history: %v", decErr) + } + + if len(msgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(msgs)) + } +} + +func TestHistoryNonMember(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_hist") + bobToken := tserver.createSession("bob_hist") + + // Alice creates and joins a channel. + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: "#secret", + }) + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "#secret", + bodyKey: []string{"secret stuff"}, + }) + + // Bob tries to read history without joining. + histURL := tserver.url( + "/api/v1/history?target=%23secret", + ) + + resp, err := doRequestAuth( + t, http.MethodGet, histURL, bobToken, nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf( + "expected 403, got %d", resp.StatusCode, + ) + } +} + +func TestChannelList(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("lister") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#listchan", + }) + + resp, err := doRequestAuth( + t, + http.MethodGet, + tserver.url("/api/v1/channels"), + token, + nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf( + "expected 200, got %d", resp.StatusCode, + ) + } + + var channels []map[string]any + + decErr := json.NewDecoder(resp.Body).Decode( + &channels, + ) + if decErr != nil { + t.Fatalf("decode channels: %v", decErr) + } + + found := false + + for _, channel := range channels { + if channel["name"] == "#listchan" { + found = true + } + } + + if !found { + t.Fatal("channel not in list") + } +} + +func TestChannelMembers(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("membertest") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#members", + }) + + resp, err := doRequestAuth( + t, + http.MethodGet, + tserver.url( + "/api/v1/channels/members/members", + ), + token, + nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf( + "expected 200, got %d", resp.StatusCode, + ) + } +} + +func TestLongPoll(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("lp_alice") + bobToken := tserver.createSession("lp_bob") + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: "#longpoll", + }) + tserver.sendCommand(bobToken, map[string]any{ + commandKey: joinCmd, toKey: "#longpoll", + }) + + _, lastID := tserver.pollMessages(bobToken, 0) + + var waitGroup sync.WaitGroup + + var pollMsgs []map[string]any + + waitGroup.Add(1) + + go func() { + defer waitGroup.Done() + + pollURL := fmt.Sprintf( + "%s"+apiMessages+"?timeout=5&after=%d", + tserver.httpServer.URL, lastID, + ) + + resp, err := doRequestAuth( + t, http.MethodGet, + pollURL, bobToken, nil, + ) + if err != nil { + return + } + + defer func() { _ = resp.Body.Close() }() + + var result struct { + Messages []map[string]any `json:"messages"` + } + + _ = json.NewDecoder(resp.Body).Decode(&result) + + pollMsgs = result.Messages + }() + + time.Sleep(200 * time.Millisecond) + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "#longpoll", + bodyKey: []string{"wake up!"}, + }) + + waitGroup.Wait() + + if !findMessage(pollMsgs, privmsgCmd, "lp_alice") { + t.Fatalf( + "long-poll didn't receive message: %v", + pollMsgs, + ) + } +} + +func TestLongPollTimeout(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("lp_timeout") + + start := time.Now() + + resp, err := doRequestAuth( + t, + http.MethodGet, + tserver.url(apiMessages+"?timeout=1"), + token, + nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + elapsed := time.Since(start) + + if elapsed < 900*time.Millisecond { + t.Fatalf( + "long-poll returned too fast: %v", elapsed, + ) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf( + "expected 200, got %d", resp.StatusCode, + ) + } +} + +func TestEphemeralChannelCleanup(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("ephemeral") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#ephemeral", + }) + tserver.sendCommand(token, map[string]any{ + commandKey: "PART", toKey: "#ephemeral", + }) + + resp, err := doRequestAuth( + t, + http.MethodGet, + tserver.url("/api/v1/channels"), + token, + nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + var channels []map[string]any + + decErr := json.NewDecoder(resp.Body).Decode( + &channels, + ) + if decErr != nil { + t.Fatalf("decode channels: %v", decErr) + } + + for _, channel := range channels { + if channel["name"] == "#ephemeral" { + t.Fatal( + "ephemeral channel should be cleaned up", + ) + } + } +} + +func TestConcurrentSessions(t *testing.T) { + tserver := newTestServer(t) + + var waitGroup sync.WaitGroup + + const concurrency = 20 + + errs := make(chan error, concurrency) + + for idx := range concurrency { + waitGroup.Add(1) + + go func(index int) { + defer waitGroup.Done() + + nick := fmt.Sprintf("conc_%d", index) + + body, err := json.Marshal( + map[string]string{"nick": nick}, + ) + if err != nil { + errs <- fmt.Errorf( + "marshal: %w", err, + ) + + return + } + + resp, err := doRequest( + t, + http.MethodPost, + tserver.url(apiSession), + bytes.NewReader(body), + ) + if err != nil { + errs <- err + + return + } + + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + errs <- fmt.Errorf( //nolint:err113 + "status %d for %s", + resp.StatusCode, nick, + ) + } + }(idx) + } + + waitGroup.Wait() + close(errs) + + for err := range errs { + if err != nil { + t.Fatalf("concurrent error: %v", err) + } + } +} + +func TestServerInfo(t *testing.T) { + tserver := newTestServer(t) + + resp, err := doRequest( + t, + http.MethodGet, + tserver.url("/api/v1/server"), + nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf( + "expected 200, got %d", resp.StatusCode, + ) + } +} + +func TestHealthcheck(t *testing.T) { + tserver := newTestServer(t) + + resp, err := doRequest( + t, + http.MethodGet, + tserver.url("/.well-known/healthcheck.json"), + nil, + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf( + "expected 200, got %d", resp.StatusCode, + ) + } + + var result map[string]any + + decErr := json.NewDecoder(resp.Body).Decode(&result) + if decErr != nil { + t.Fatalf("decode healthcheck: %v", decErr) + } + + if result[statusKey] != "ok" { + t.Fatalf( + "expected ok status, got %v", + result[statusKey], + ) + } +} + +func TestNickBroadcastToChannels(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("nick_a") + bobToken := tserver.createSession("nick_b") + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: "#nicktest", + }) + tserver.sendCommand(bobToken, map[string]any{ + commandKey: joinCmd, toKey: "#nicktest", + }) + + _, lastID := tserver.pollMessages(bobToken, 0) + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: "NICK", + bodyKey: []string{"nick_a_new"}, + }) + + msgs, _ := tserver.pollMessages(bobToken, lastID) + + if !findMessage(msgs, "NICK", "nick_a") { + t.Fatalf( + "bob didn't get nick change: %v", msgs, + ) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 11b8942..d6e2014 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -4,9 +4,11 @@ package handlers import ( "context" "encoding/json" + "errors" "log/slog" "net/http" + "git.eeqj.de/sneak/chat/internal/broker" "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/chat/internal/globals" @@ -15,6 +17,8 @@ import ( "go.uber.org/fx" ) +var errUnauthorized = errors.New("unauthorized") + // Params defines the dependencies for creating Handlers. type Params struct { fx.In @@ -31,32 +35,64 @@ type Handlers struct { params *Params log *slog.Logger hc *healthcheck.Healthcheck + broker *broker.Broker } // New creates a new Handlers instance. -func New(lc fx.Lifecycle, params Params) (*Handlers, error) { - s := new(Handlers) - s.params = ¶ms - s.log = params.Logger.Get() - s.hc = params.Healthcheck +func New( + lifecycle fx.Lifecycle, + params Params, +) (*Handlers, error) { + hdlr := &Handlers{ + params: ¶ms, + log: params.Logger.Get(), + hc: params.Healthcheck, + broker: broker.New(), + } - lc.Append(fx.Hook{ + lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { return nil }, + OnStop: func(_ context.Context) error { + return nil + }, }) - return s, nil + return hdlr, nil } -func (s *Handlers) respondJSON(w http.ResponseWriter, _ *http.Request, data any, status int) { - w.WriteHeader(status) - w.Header().Set("Content-Type", "application/json") +func (hdlr *Handlers) respondJSON( + writer http.ResponseWriter, + _ *http.Request, + data any, + status int, +) { + writer.Header().Set( + "Content-Type", + "application/json; charset=utf-8", + ) + 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, + ) +} diff --git a/internal/handlers/healthcheck.go b/internal/handlers/healthcheck.go index 1666ebb..99f0af2 100644 --- a/internal/handlers/healthcheck.go +++ b/internal/handlers/healthcheck.go @@ -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) } } diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go index 37b2983..2aacc84 100644 --- a/internal/healthcheck/healthcheck.go +++ b/internal/healthcheck/healthcheck.go @@ -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 = ¶ms - s.log = params.Logger.Get() +func New( + lifecycle fx.Lifecycle, params Params, +) (*Healthcheck, error) { + hcheck := &Healthcheck{ //nolint:exhaustruct // StartupTime set in OnStart + params: ¶ms, + 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) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 42b4fa2..518c86a 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -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, ) } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index f048f58..a69c58c 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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 = ¶ms - s.log = params.Logger.Get() +func New( + _ fx.Lifecycle, params Params, +) (*Middleware, error) { + mware := &Middleware{ + params: ¶ms, + 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{ diff --git a/internal/models/auth_token.go b/internal/models/auth_token.go deleted file mode 100644 index c2c3fd1..0000000 --- a/internal/models/auth_token.go +++ /dev/null @@ -1,26 +0,0 @@ -package models - -import ( - "context" - "time" -) - -// AuthToken represents an authentication token for a user session. -type AuthToken struct { - Base - - Token string `json:"-"` - UserID string `json:"userId"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` -} - -// User returns the user who owns this token. -func (t *AuthToken) User(ctx context.Context) (*User, error) { - if ul := t.GetUserLookup(); ul != nil { - return ul.GetUserByID(ctx, t.UserID) - } - - return nil, ErrUserLookupNotAvailable -} diff --git a/internal/models/channel.go b/internal/models/channel.go deleted file mode 100644 index addafc9..0000000 --- a/internal/models/channel.go +++ /dev/null @@ -1,96 +0,0 @@ -package models - -import ( - "context" - "time" -) - -// Channel represents a chat channel. -type Channel struct { - Base - - ID string `json:"id"` - Name string `json:"name"` - Topic string `json:"topic"` - Modes string `json:"modes"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// Members returns all users who are members of this channel. -func (c *Channel) Members(ctx context.Context) ([]*ChannelMember, error) { - rows, err := c.GetDB().QueryContext(ctx, ` - SELECT cm.channel_id, cm.user_id, cm.modes, cm.joined_at, - u.nick - FROM channel_members cm - JOIN users u ON u.id = cm.user_id - WHERE cm.channel_id = ? - ORDER BY cm.joined_at`, - c.ID, - ) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - members := []*ChannelMember{} - - for rows.Next() { - m := &ChannelMember{} - m.SetDB(c.db) - - err = rows.Scan( - &m.ChannelID, &m.UserID, &m.Modes, - &m.JoinedAt, &m.Nick, - ) - if err != nil { - return nil, err - } - - members = append(members, m) - } - - return members, rows.Err() -} - -// RecentMessages returns the most recent messages in this channel. -func (c *Channel) RecentMessages( - ctx context.Context, - limit int, -) ([]*Message, error) { - rows, err := c.GetDB().QueryContext(ctx, ` - SELECT id, ts, from_user_id, from_nick, - target, type, body, meta, created_at - FROM messages - WHERE target = ? - ORDER BY ts DESC - LIMIT ?`, - c.Name, limit, - ) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - messages := []*Message{} - - for rows.Next() { - msg := &Message{} - msg.SetDB(c.db) - - err = rows.Scan( - &msg.ID, &msg.Timestamp, &msg.FromUserID, - &msg.FromNick, &msg.Target, &msg.Type, - &msg.Body, &msg.Meta, &msg.CreatedAt, - ) - if err != nil { - return nil, err - } - - messages = append(messages, msg) - } - - return messages, rows.Err() -} diff --git a/internal/models/channel_member.go b/internal/models/channel_member.go deleted file mode 100644 index 59586c7..0000000 --- a/internal/models/channel_member.go +++ /dev/null @@ -1,35 +0,0 @@ -package models - -import ( - "context" - "time" -) - -// ChannelMember represents a user's membership in a channel. -type ChannelMember struct { - Base - - ChannelID string `json:"channelId"` - UserID string `json:"userId"` - Modes string `json:"modes"` - JoinedAt time.Time `json:"joinedAt"` - Nick string `json:"nick"` // denormalized from users table -} - -// User returns the full User for this membership. -func (cm *ChannelMember) User(ctx context.Context) (*User, error) { - if ul := cm.GetUserLookup(); ul != nil { - return ul.GetUserByID(ctx, cm.UserID) - } - - return nil, ErrUserLookupNotAvailable -} - -// Channel returns the full Channel for this membership. -func (cm *ChannelMember) Channel(ctx context.Context) (*Channel, error) { - if cl := cm.GetChannelLookup(); cl != nil { - return cl.GetChannelByID(ctx, cm.ChannelID) - } - - return nil, ErrChannelLookupNotAvailable -} diff --git a/internal/models/message.go b/internal/models/message.go deleted file mode 100644 index 652ae0d..0000000 --- a/internal/models/message.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -import ( - "time" -) - -// Message represents a chat message (channel or DM). -type Message struct { - Base - - ID string `json:"id"` - Timestamp time.Time `json:"ts"` - FromUserID string `json:"fromUserId"` - FromNick string `json:"from"` - Target string `json:"to"` - Type string `json:"type"` - Body string `json:"body"` - Meta string `json:"meta"` - CreatedAt time.Time `json:"createdAt"` -} diff --git a/internal/models/message_queue.go b/internal/models/message_queue.go deleted file mode 100644 index 616cbc3..0000000 --- a/internal/models/message_queue.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -import ( - "time" -) - -// MessageQueueEntry represents a pending message delivery for a user. -type MessageQueueEntry struct { - Base - - ID int64 `json:"id"` - UserID string `json:"userId"` - MessageID string `json:"messageId"` - QueuedAt time.Time `json:"queuedAt"` -} diff --git a/internal/models/model.go b/internal/models/model.go deleted file mode 100644 index fdaa90f..0000000 --- a/internal/models/model.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package models defines the data models used by the chat application. -// All model structs embed Base, which provides database access for -// relation-fetching methods directly on model instances. -package models - -import ( - "context" - "database/sql" - "errors" -) - -// DB is the interface that models use to query the database. -// This avoids a circular import with the db package. -type DB interface { - GetDB() *sql.DB -} - -// UserLookup provides user lookup by ID without circular imports. -type UserLookup interface { - GetUserByID(ctx context.Context, id string) (*User, error) -} - -// ChannelLookup provides channel lookup by ID without circular imports. -type ChannelLookup interface { - GetChannelByID(ctx context.Context, id string) (*Channel, error) -} - -// Sentinel errors for model lookup methods. -var ( - ErrUserLookupNotAvailable = errors.New("user lookup not available") - ErrChannelLookupNotAvailable = errors.New("channel lookup not available") -) - -// Base is embedded in all model structs to provide database access. -type Base struct { - db DB -} - -// SetDB injects the database reference into a model. -func (b *Base) SetDB(d DB) { - b.db = d -} - -// GetDB returns the database interface for use in model methods. -func (b *Base) GetDB() *sql.DB { - return b.db.GetDB() -} - -// GetUserLookup returns the DB as a UserLookup if it implements the interface. -func (b *Base) GetUserLookup() UserLookup { //nolint:ireturn - if ul, ok := b.db.(UserLookup); ok { - return ul - } - - return nil -} - -// GetChannelLookup returns the DB as a ChannelLookup if it implements the interface. -func (b *Base) GetChannelLookup() ChannelLookup { //nolint:ireturn - if cl, ok := b.db.(ChannelLookup); ok { - return cl - } - - return nil -} diff --git a/internal/models/server_link.go b/internal/models/server_link.go deleted file mode 100644 index 004ef67..0000000 --- a/internal/models/server_link.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "time" -) - -// ServerLink represents a federation peer server configuration. -type ServerLink struct { - Base - - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - SharedKeyHash string `json:"-"` - IsActive bool `json:"isActive"` - CreatedAt time.Time `json:"createdAt"` - LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` -} diff --git a/internal/models/session.go b/internal/models/session.go deleted file mode 100644 index 295def2..0000000 --- a/internal/models/session.go +++ /dev/null @@ -1,26 +0,0 @@ -package models - -import ( - "context" - "time" -) - -// Session represents a server-held user session. -type Session struct { - Base - - ID string `json:"id"` - UserID string `json:"userId"` - CreatedAt time.Time `json:"createdAt"` - LastActiveAt time.Time `json:"lastActiveAt"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` -} - -// User returns the user who owns this session. -func (s *Session) User(ctx context.Context) (*User, error) { - if ul := s.GetUserLookup(); ul != nil { - return ul.GetUserByID(ctx, s.UserID) - } - - return nil, ErrUserLookupNotAvailable -} diff --git a/internal/models/user.go b/internal/models/user.go deleted file mode 100644 index f3d778f..0000000 --- a/internal/models/user.go +++ /dev/null @@ -1,92 +0,0 @@ -package models - -import ( - "context" - "time" -) - -// User represents a registered user account. -type User struct { - Base - - ID string `json:"id"` - Nick string `json:"nick"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` -} - -// Channels returns all channels the user is a member of. -func (u *User) Channels(ctx context.Context) ([]*Channel, error) { - rows, err := u.GetDB().QueryContext(ctx, ` - SELECT c.id, c.name, c.topic, c.modes, c.created_at, c.updated_at - FROM channels c - JOIN channel_members cm ON cm.channel_id = c.id - WHERE cm.user_id = ? - ORDER BY c.name`, - u.ID, - ) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - channels := []*Channel{} - - for rows.Next() { - c := &Channel{} - c.SetDB(u.db) - - err = rows.Scan( - &c.ID, &c.Name, &c.Topic, &c.Modes, - &c.CreatedAt, &c.UpdatedAt, - ) - if err != nil { - return nil, err - } - - channels = append(channels, c) - } - - return channels, rows.Err() -} - -// QueuedMessages returns undelivered messages for this user. -func (u *User) QueuedMessages(ctx context.Context) ([]*Message, error) { - rows, err := u.GetDB().QueryContext(ctx, ` - SELECT m.id, m.ts, m.from_user_id, m.from_nick, - m.target, m.type, m.body, m.meta, m.created_at - FROM messages m - JOIN message_queue mq ON mq.message_id = m.id - WHERE mq.user_id = ? - ORDER BY mq.queued_at ASC`, - u.ID, - ) - if err != nil { - return nil, err - } - - defer func() { _ = rows.Close() }() - - messages := []*Message{} - - for rows.Next() { - msg := &Message{} - msg.SetDB(u.db) - - err = rows.Scan( - &msg.ID, &msg.Timestamp, &msg.FromUserID, - &msg.FromNick, &msg.Target, &msg.Type, - &msg.Body, &msg.Meta, &msg.CreatedAt, - ) - if err != nil { - return nil, err - } - - messages = append(messages, msg) - } - - return messages, rows.Err() -} diff --git a/internal/server/http.go b/internal/server/http.go index 4b01db2..979f4cb 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -4,6 +4,6 @@ import "time" const ( httpReadTimeout = 10 * time.Second - httpWriteTimeout = 10 * time.Second + httpWriteTimeout = 60 * time.Second maxHeaderBytes = 1 << 20 ) diff --git a/internal/server/routes.go b/internal/server/routes.go index 9a70e3c..ba49ad9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -16,92 +16,138 @@ import ( const routeTimeout = 60 * time.Second -// SetupRoutes configures the HTTP routes and middleware chain. -func (s *Server) SetupRoutes() { - s.router = chi.NewRouter() +// SetupRoutes configures the HTTP routes and middleware. +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("/.well-known/healthcheck.json", s.h.HandleHealthCheck()) + // Health check. + srv.router.Get( + "/.well-known/healthcheck.json", + 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", http.HandlerFunc(promhttp.Handler().ServeHTTP)) + 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()) + // API v1. + srv.router.Route( + "/api/v1", + func(router chi.Router) { + router.Get( + "/server", + srv.handlers.HandleServerInfo(), + ) + router.Post( + "/session", + srv.handlers.HandleCreateSession(), + ) + router.Get( + "/state", + srv.handlers.HandleState(), + ) + 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(), + ) + }, + ) - // Unified state and message endpoints - r.Get("/state", s.h.HandleState()) - r.Get("/messages", s.h.HandleGetMessages()) - r.Post("/messages", s.h.HandleSendCommand()) - r.Get("/history", s.h.HandleGetHistory()) + // Serve embedded SPA. + srv.setupSPA() +} - // Channels - r.Get("/channels", s.h.HandleListAllChannels()) - r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) - }) - - // Serve embedded SPA +func (srv *Server) setupSPA() { distFS, err := fs.Sub(web.Dist, "dist") if err != nil { - s.log.Error("failed to get web dist filesystem", "error", err) - } else { - fileServer := http.FileServer(http.FS(distFS)) - - s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { - s.serveSPA(distFS, fileServer, w, r) - }) - } -} - -func (s *Server) serveSPA( - distFS fs.FS, - fileServer http.Handler, - w http.ResponseWriter, - r *http.Request, -) { - readFS, ok := distFS.(fs.ReadFileFS) - if !ok { - http.Error(w, "filesystem error", http.StatusInternalServerError) + srv.log.Error( + "failed to get web dist filesystem", + "error", err, + ) return } - // Try to serve the file; fall back to index.html for SPA routing. - f, err := readFS.ReadFile(r.URL.Path[1:]) - if err != nil || len(f) == 0 { - indexHTML, _ := readFS.ReadFile("index.html") + fileServer := http.FileServer(http.FS(distFS)) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(indexHTML) + srv.router.Get("/*", func( + writer http.ResponseWriter, + request *http.Request, + ) { + readFS, ok := distFS.(fs.ReadFileFS) + if !ok { + fileServer.ServeHTTP(writer, request) - return - } + return + } - fileServer.ServeHTTP(w, r) + 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(writer, request) + + return + } + + writer.Header().Set( + "Content-Type", + "text/html; charset=utf-8", + ) + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(indexHTML) + + return + } + + fileServer.ServeHTTP(writer, request) + }) } diff --git a/internal/server/server.go b/internal/server/server.go index a9e7517..b6d04c5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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,120 +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) + 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() } } } diff --git a/web/dist/app.js b/web/dist/app.js index 2a5d789..38642c2 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1 +1,464 @@ -(()=>{var te,b,Ce,Ge,O,ge,Se,xe,Te,ae,ie,se,Qe,q={},Ee=[],Xe=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ne=Array.isArray;function U(e,t){for(var n in t)e[n]=t[n];return e}function le(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var _,i,o,s={};for(o in t)o=="key"?_=t[o]:o=="ref"?i=t[o]:s[o]=t[o];if(arguments.length>2&&(s.children=arguments.length>3?te.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)s[o]===void 0&&(s[o]=e.defaultProps[o]);return Y(e,s,_,i,null)}function Y(e,t,n,_,i){var o={type:e,props:t,key:n,ref:_,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++Ce,__i:-1,__u:0};return i==null&&b.vnode!=null&&b.vnode(o),o}function _e(e){return e.children}function Z(e,t){this.props=e,this.context=t}function j(e,t){if(t==null)return e.__?j(e.__,e.__i+1):null;for(var n;tl&&O.sort(xe),e=O.shift(),l=O.length,e.__d&&(n=void 0,_=void 0,i=(_=(t=e).__v).__e,o=[],s=[],t.__P&&((n=U({},_)).__v=_.__v+1,b.vnode&&b.vnode(n),ue(t.__P,n,_,t.__n,t.__P.namespaceURI,32&_.__u?[i]:null,o,i??j(_),!!(32&_.__u),s),n.__v=_.__v,n.__.__k[n.__i]=n,He(o,n,s),_.__e=_.__=null,n.__e!=i&&Pe(n)));ee.__r=0}function Ie(e,t,n,_,i,o,s,l,d,c,h){var r,u,f,k,P,w,g,y=_&&_.__k||Ee,D=t.length;for(d=Ye(n,t,y,d,D),r=0;r0?s=e.__k[o]=Y(s.type,s.props,s.key,s.ref?s.ref:null,s.__v):e.__k[o]=s,d=o+u,s.__=e,s.__b=e.__b+1,l=null,(c=s.__i=Ze(s,n,d,r))!=-1&&(r--,(l=n[c])&&(l.__u|=2)),l==null||l.__v==null?(c==-1&&(i>h?u--:id?u--:u++,s.__u|=4))):e.__k[o]=null;if(r)for(o=0;o(h?1:0)){for(i=n-1,o=n+1;i>=0||o=0?i--:o++])!=null&&(2&c.__u)==0&&l==c.key&&d==c.type)return s}return-1}function ke(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Xe.test(t)?n:n+"px"}function X(e,t,n,_,i){var o,s;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof _=="string"&&(e.style.cssText=_=""),_)for(t in _)n&&t in n||ke(e.style,t,"");if(n)for(t in n)_&&n[t]==_[t]||ke(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(Te,"$1")),s=t.toLowerCase(),t=s in e||t=="onFocusOut"||t=="onFocusIn"?s.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?_?n.u=_.u:(n.u=ae,e.addEventListener(t,o?se:ie,o)):e.removeEventListener(t,o?se:ie,o);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function we(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ae++;else if(t.t0?e:ne(e)?e.map(De):U({},e)}function et(e,t,n,_,i,o,s,l,d){var c,h,r,u,f,k,P,w=n.props||q,g=t.props,y=t.type;if(y=="svg"?i="http://www.w3.org/2000/svg":y=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),o!=null){for(c=0;c=n.__.length&&n.__.push({}),n.__[e]}function I(e){return K=1,nt(qe,e)}function nt(e,t,n){var _=he(z++,2);if(_.t=e,!_.__c&&(_.__=[n?n(t):qe(void 0,t),function(l){var d=_.__N?_.__N[0]:_.__[0],c=_.t(d,l);d!==c&&(_.__N=[c,_.__[1]],_.__c.setState({}))}],_.__c=S,!S.__f)){var i=function(l,d,c){if(!_.__c.__H)return!0;var h=_.__c.__H.__.filter(function(u){return!!u.__c});if(h.every(function(u){return!u.__N}))return!o||o.call(this,l,d,c);var r=_.__c.props!==l;return h.forEach(function(u){if(u.__N){var f=u.__[0];u.__=u.__N,u.__N=void 0,f!==u.__[0]&&(r=!0)}}),o&&o.call(this,l,d,c)||r};S.__f=!0;var o=S.shouldComponentUpdate,s=S.componentWillUpdate;S.componentWillUpdate=function(l,d,c){if(this.__e){var h=o;o=void 0,i(l,d,c),o=h}s&&s.call(this,l,d,c)},S.shouldComponentUpdate=i}return _.__N||_.__}function W(e,t){var n=he(z++,3);!x.__s&&Ve(n.__H,t)&&(n.__=e,n.u=t,S.__H.__h.push(n))}function G(e){return K=5,Be(function(){return{current:e}},[])}function Be(e,t){var n=he(z++,7);return Ve(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function oe(e,t){return K=8,Be(function(){return e},t)}function _t(){for(var e;e=Je.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(re),e.__H.__h.forEach(de),e.__H.__h=[]}catch(t){e.__H.__h=[],x.__e(t,e.__v)}}x.__b=function(e){S=null,Ue&&Ue(e)},x.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),We&&We(e,t)},x.__r=function(e){Le&&Le(e),z=0;var t=(S=e.__c).__H;t&&(pe===S?(t.__h=[],S.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(re),t.__h.forEach(de),t.__h=[],z=0)),pe=S},x.diffed=function(e){Fe&&Fe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Je.push(t)!==1&&Ae===x.requestAnimationFrame||((Ae=x.requestAnimationFrame)||rt)(_t)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),pe=S=null},x.__c=function(e,t){t.some(function(n){try{n.__h.forEach(re),n.__h=n.__h.filter(function(_){return!_.__||de(_)})}catch(_){t.some(function(i){i.__h&&(i.__h=[])}),t=[],x.__e(_,n.__v)}}),Oe&&Oe(e,t)},x.unmount=function(e){je&&je(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(_){try{re(_)}catch(i){t=i}}),n.__H=void 0,t&&x.__e(t,n.__v))};var Re=typeof requestAnimationFrame=="function";function rt(e){var t,n=function(){clearTimeout(_),Re&&cancelAnimationFrame(t),setTimeout(e)},_=setTimeout(n,35);Re&&(t=requestAnimationFrame(n))}function re(e){var t=S,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),S=t}function de(e){var t=S;e.__c=e.__(),S=t}function Ve(e,t){return!e||e.length!==t.length||t.some(function(n,_){return n!==e[_]})}function qe(e,t){return typeof t=="function"?t(e):t}var ot="/api/v1";function H(e,t={}){let n=localStorage.getItem("chat_token"),_={"Content-Type":"application/json",...t.headers||{}};return n&&(_.Authorization=`Bearer ${n}`),fetch(ot+e,{...t,headers:_}).then(async i=>{let o=await i.json().catch(()=>null);if(!i.ok)throw{status:i.status,data:o};return o})}function it(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function Ke(e){let t=0;for(let _=0;_{H("/server").then(u=>{u.name&&d(u.name),u.motd&&s(u.motd)}).catch(()=>{});let r=localStorage.getItem("chat_token");r&&H("/me").then(u=>e(u.nick,r)).catch(()=>localStorage.removeItem("chat_token")),c.current?.focus()},[]),m("div",{class:"login-screen"},m("h1",null,l),o&&m("div",{class:"motd"},o),m("form",{onSubmit:async r=>{r.preventDefault(),i("");try{let u=await H("/register",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("chat_token",u.token),e(u.nick,u.token)}catch(u){i(u.data?.error||"Connection failed")}}},m("input",{ref:c,type:"text",placeholder:"Choose a nickname...",value:t,onInput:r=>n(r.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),_&&m("div",{class:"error"},_))}function ze({msg:e}){return m("div",{class:`message ${e.system?"system":""}`},m("span",{class:"timestamp"},it(e.createdAt)),m("span",{class:"nick",style:{color:e.system?void 0:Ke(e.nick)}},e.nick),m("span",{class:"content"},e.content))}function ct(){let[e,t]=I(!1),[n,_]=I(""),[i,o]=I([{type:"server",name:"Server"}]),[s,l]=I(0),[d,c]=I({server:[]}),[h,r]=I({}),[u,f]=I(""),[k,P]=I(""),[w,g]=I(0),y=G(),D=G(),N=G(),$=oe((a,p)=>{c(v=>({...v,[a]:[...v[a]||[],p]}))},[]),E=oe((a,p)=>{$(a,{id:Date.now(),nick:"*",content:p,createdAt:new Date().toISOString(),system:!0})},[$]),Q=oe((a,p)=>{_(a),t(!0),E("server",`Connected as ${a}`),H("/server").then(v=>{v.motd&&E("server",`MOTD: ${v.motd}`)}).catch(()=>{})},[E]);W(()=>{if(!e)return;let a=!0,p=async()=>{try{let v=await H(`/poll?after=${w}`);if(!a)return;let T=w;for(let C of v)if(C.id>T&&(T=C.id),C.isDm){let B=C.nick===n?C.dmTarget:C.nick;o(V=>V.find(ye=>ye.type==="dm"&&ye.name===B)?V:[...V,{type:"dm",name:B}]),$(B,C)}else C.channel&&$(C.channel,C);T>w&&g(T)}catch{}};return N.current=setInterval(p,1500),p(),()=>{a=!1,clearInterval(N.current)}},[e,w,n,$]),W(()=>{if(!e)return;let a=i[s];if(!a||a.type!=="channel")return;let p=a.name.replace("#","");H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{});let v=setInterval(()=>{H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{})},5e3);return()=>clearInterval(v)},[e,s,i]),W(()=>{y.current?.scrollIntoView({behavior:"smooth"})},[d,s]),W(()=>{D.current?.focus()},[s]);let L=async a=>{if(a){a=a.trim(),a.startsWith("#")||(a="#"+a);try{await H("/channels/join",{method:"POST",body:JSON.stringify({channel:a})}),o(p=>p.find(v=>v.type==="channel"&&v.name===a)?p:[...p,{type:"channel",name:a}]),l(i.length),E(a,`Joined ${a}`),P("")}catch(p){E("server",`Failed to join ${a}: ${p.data?.error||"error"}`)}}},F=async a=>{let p=a.replace("#","");try{await H(`/channels/${p}/part`,{method:"DELETE"})}catch{}o(v=>v.filter(C=>!(C.type==="channel"&&C.name===a))),l(0)},R=a=>{let p=i[a];p.type==="channel"?F(p.name):p.type==="dm"&&(o(v=>v.filter((T,C)=>C!==a)),s>=a&&l(Math.max(0,s-1)))},M=a=>{o(p=>p.find(v=>v.type==="dm"&&v.name===a)?p:[...p,{type:"dm",name:a}]),l(i.findIndex(p=>p.type==="dm"&&p.name===a)||i.length)},A=async()=>{let a=u.trim();if(!a)return;f("");let p=i[s];if(!(!p||p.type==="server")){if(a.startsWith("/")){let v=a.split(" "),T=v[0].toLowerCase();if(T==="/join"&&v[1]){L(v[1]);return}if(T==="/part"){p.type==="channel"&&F(p.name);return}if(T==="/msg"&&v[1]&&v.slice(2).join(" ")){let C=v[1],B=v.slice(2).join(" ");try{await H(`/dm/${C}/messages`,{method:"POST",body:JSON.stringify({content:B})}),M(C)}catch(V){E("server",`Failed to send DM: ${V.data?.error||"error"}`)}return}if(T==="/nick"){E("server","Nick changes not yet supported");return}E("server",`Unknown command: ${T}`);return}if(p.type==="channel"){let v=p.name.replace("#","");try{await H(`/channels/${v}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(T){E(p.name,`Send failed: ${T.data?.error||"error"}`)}}else if(p.type==="dm")try{await H(`/dm/${p.name}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(v){E(p.name,`Send failed: ${v.data?.error||"error"}`)}}};if(!e)return m(st,{onLogin:Q});let J=i[s]||i[0],me=d[J.name]||[],ve=h[J.name]||[];return m("div",{class:"app"},m("div",{class:"tab-bar"},i.map((a,p)=>m("div",{class:`tab ${p===s?"active":""}`,onClick:()=>l(p)},a.type==="dm"?`\u2192${a.name}`:a.name,a.type!=="server"&&m("span",{class:"close-btn",onClick:v=>{v.stopPropagation(),R(p)}},"\xD7"))),m("div",{class:"join-dialog"},m("input",{placeholder:"#channel",value:k,onInput:a=>P(a.target.value),onKeyDown:a=>a.key==="Enter"&&L(k)}),m("button",{onClick:()=>L(k)},"Join"))),m("div",{class:"content"},m("div",{class:"messages-pane"},J.type==="server"?m("div",{class:"server-messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})):m(Fragment,null,m("div",{class:"messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})),m("div",{class:"input-bar"},m("input",{ref:D,placeholder:`Message ${J.name}...`,value:u,onInput:a=>f(a.target.value),onKeyDown:a=>a.key==="Enter"&&A()}),m("button",{onClick:A},"Send")))),J.type==="channel"&&m("div",{class:"user-list"},m("h3",null,"Users (",ve.length,")"),ve.map(a=>m("div",{class:"user",onClick:()=>M(a.nick),style:{color:Ke(a.nick)}},a.nick)))))}$e(m(ct,null),document.getElementById("root"));})(); +(()=>{ +// Minimal Preact-like runtime using raw DOM for simplicity and zero build step. +// This replaces the previous Preact SPA with a vanilla JS implementation. + +const API = '/api/v1'; +let token = localStorage.getItem('chat_token'); +let myNick = ''; +let myUID = 0; +let lastQueueID = 0; +let pollController = null; +let channels = []; // [{name, topic}] +let activeTab = null; // '#channel' or 'nick' or 'server' +let messages = {}; // target -> [{command,from,to,body,ts,system}] +let unread = {}; // target -> count +let members = {}; // '#channel' -> [{nick}] + +function $(sel, parent) { return (parent||document).querySelector(sel); } +function $$(sel, parent) { return [...(parent||document).querySelectorAll(sel)]; } +function el(tag, attrs, ...children) { + const e = document.createElement(tag); + if (attrs) Object.entries(attrs).forEach(([k,v]) => { + if (k === 'class') e.className = v; + else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v); + else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); + else e.setAttribute(k, v); + }); + children.flat(Infinity).forEach(c => { + if (c == null) return; + e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + }); + return e; +} + +async function api(path, opts = {}) { + const headers = {'Content-Type': 'application/json', ...(opts.headers||{})}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const resp = await fetch(API + path, {...opts, headers, signal: opts.signal}); + const data = await resp.json().catch(() => null); + if (!resp.ok) throw {status: resp.status, data}; + return data; +} + +function nickColor(nick) { + let h = 0; + for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); + return `hsl(${Math.abs(h) % 360}, 70%, 65%)`; +} + +function formatTime(ts) { + return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); +} + +function addMessage(target, msg) { + if (!messages[target]) messages[target] = []; + messages[target].push(msg); + if (messages[target].length > 500) messages[target] = messages[target].slice(-400); + if (target !== activeTab) { + unread[target] = (unread[target] || 0) + 1; + renderTabs(); + } + if (target === activeTab) renderMessages(); +} + +function addSystemMessage(target, text) { + addMessage(target, {command: 'SYSTEM', from: '*', body: [text], ts: new Date().toISOString(), system: true}); +} + +// --- Rendering --- + +function renderApp() { + const root = $('#root'); + root.innerHTML = ''; + root.appendChild(el('div', {class:'app'}, + el('div', {class:'tab-bar', id:'tabs'}), + el('div', {class:'content'}, + el('div', {class:'messages-pane'}, + el('div', {class:'messages', id:'msg-list'}), + el('div', {class:'input-bar', id:'input-bar'}, + el('input', {id:'msg-input', placeholder:'Message...', onKeydown: e => { if(e.key==='Enter') sendInput(); }}), + el('button', {onClick: sendInput}, 'Send') + ) + ), + el('div', {class:'user-list', id:'user-list'}) + ) + )); + renderTabs(); + renderMessages(); + renderMembers(); + $('#msg-input')?.focus(); +} + +function renderTabs() { + const container = $('#tabs'); + if (!container) return; + container.innerHTML = ''; + + // Server tab + const serverTab = el('div', {class: `tab ${activeTab === 'server' ? 'active' : ''}`, onClick: () => switchTab('server')}, 'Server'); + container.appendChild(serverTab); + + // Channel tabs + channels.forEach(ch => { + const badge = unread[ch.name] ? ` (${unread[ch.name]})` : ''; + const tab = el('div', {class: `tab ${activeTab === ch.name ? 'active' : ''}`}, + el('span', {onClick: () => switchTab(ch.name)}, ch.name + badge), + el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); partChannel(ch.name); }}, '×') + ); + container.appendChild(tab); + }); + + // DM tabs + Object.keys(messages).filter(k => !k.startsWith('#') && k !== 'server').forEach(nick => { + const badge = unread[nick] ? ` (${unread[nick]})` : ''; + const tab = el('div', {class: `tab ${activeTab === nick ? 'active' : ''}`}, + el('span', {onClick: () => switchTab(nick)}, '→' + nick + badge), + el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); delete messages[nick]; delete unread[nick]; if(activeTab===nick) switchTab('server'); else renderTabs(); }}, '×') + ); + container.appendChild(tab); + }); + + // Join input + const joinDiv = el('div', {class:'join-dialog'}, + el('input', {id:'join-input', placeholder:'#channel', onKeydown: e => { if(e.key==='Enter') joinFromInput(); }}), + el('button', {onClick: joinFromInput}, 'Join') + ); + container.appendChild(joinDiv); +} + +function renderMessages() { + const container = $('#msg-list'); + if (!container) return; + const msgs = messages[activeTab] || []; + container.innerHTML = ''; + msgs.forEach(m => { + const isSystem = m.system || ['JOIN','PART','QUIT','NICK','TOPIC'].includes(m.command); + const bodyText = Array.isArray(m.body) ? m.body.join('\n') : (m.body || ''); + + let displayText = bodyText; + if (m.command === 'JOIN') displayText = `${m.from} has joined ${m.to}`; + else if (m.command === 'PART') displayText = `${m.from} has left ${m.to}` + (bodyText ? ` (${bodyText})` : ''); + else if (m.command === 'QUIT') displayText = `${m.from} has quit` + (bodyText ? ` (${bodyText})` : ''); + else if (m.command === 'NICK') displayText = `${m.from} is now known as ${bodyText}`; + else if (m.command === 'TOPIC') displayText = `${m.from} set topic: ${bodyText}`; + + const msgEl = el('div', {class: `message ${isSystem ? 'system' : ''}`}, + el('span', {class:'timestamp'}, m.ts ? formatTime(m.ts) : ''), + isSystem + ? el('span', {class:'nick'}, '*') + : el('span', {class:'nick', style:{color: nickColor(m.from)}}, m.from), + el('span', {class:'content'}, displayText) + ); + container.appendChild(msgEl); + }); + container.scrollTop = container.scrollHeight; +} + +function renderMembers() { + const container = $('#user-list'); + if (!container) return; + if (!activeTab || !activeTab.startsWith('#')) { + container.innerHTML = ''; + return; + } + const mems = members[activeTab] || []; + container.innerHTML = ''; + container.appendChild(el('h3', null, `Users (${mems.length})`)); + mems.forEach(m => { + container.appendChild(el('div', {class:'user', style:{color: nickColor(m.nick)}, onClick: () => openDM(m.nick)}, m.nick)); + }); +} + +function switchTab(target) { + activeTab = target; + unread[target] = 0; + renderTabs(); + renderMessages(); + renderMembers(); + if (activeTab?.startsWith('#')) fetchMembers(activeTab); + $('#msg-input')?.focus(); +} + +// --- Actions --- + +async function joinFromInput() { + const input = $('#join-input'); + if (!input) return; + let name = input.value.trim(); + if (!name) return; + if (!name.startsWith('#')) name = '#' + name; + input.value = ''; + try { + await api('/messages', {method:'POST', body: JSON.stringify({command:'JOIN', to: name})}); + } catch(e) { + addSystemMessage('server', `Failed to join ${name}: ${e.data?.error || 'error'}`); + } +} + +async function partChannel(name) { + try { + await api('/messages', {method:'POST', body: JSON.stringify({command:'PART', to: name})}); + } catch(e) {} + channels = channels.filter(c => c.name !== name); + delete members[name]; + if (activeTab === name) switchTab('server'); + else renderTabs(); +} + +function openDM(nick) { + if (nick === myNick) return; + if (!messages[nick]) messages[nick] = []; + switchTab(nick); +} + +async function sendInput() { + const input = $('#msg-input'); + if (!input) return; + const text = input.value.trim(); + if (!text) return; + input.value = ''; + + if (text.startsWith('/')) { + const parts = text.split(' '); + const cmd = parts[0].toLowerCase(); + if (cmd === '/join' && parts[1]) { $('#join-input').value = parts[1]; joinFromInput(); return; } + if (cmd === '/part') { if(activeTab?.startsWith('#')) partChannel(activeTab); return; } + if (cmd === '/nick' && parts[1]) { + try { + await api('/messages', {method:'POST', body: JSON.stringify({command:'NICK', body:[parts[1]]})}); + } catch(e) { + addSystemMessage(activeTab||'server', `Nick change failed: ${e.data?.error || 'error'}`); + } + return; + } + if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { + const target = parts[1]; + const msg = parts.slice(2).join(' '); + try { + await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: target, body:[msg]})}); + openDM(target); + } catch(e) { + addSystemMessage(activeTab||'server', `DM failed: ${e.data?.error || 'error'}`); + } + return; + } + if (cmd === '/quit') { + try { await api('/messages', {method:'POST', body: JSON.stringify({command:'QUIT'})}); } catch(e) {} + localStorage.removeItem('chat_token'); + location.reload(); + return; + } + addSystemMessage(activeTab||'server', `Unknown command: ${cmd}`); + return; + } + + if (!activeTab || activeTab === 'server') { + addSystemMessage('server', 'Select a channel or user to send messages'); + return; + } + + try { + await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: activeTab, body:[text]})}); + } catch(e) { + addSystemMessage(activeTab, `Send failed: ${e.data?.error || 'error'}`); + } +} + +async function fetchMembers(channel) { + try { + const name = channel.replace('#',''); + const data = await api(`/channels/${name}/members`); + members[channel] = data; + renderMembers(); + } catch(e) {} +} + +// --- Polling --- + +async function pollLoop() { + while (true) { + try { + if (pollController) pollController.abort(); + pollController = new AbortController(); + const data = await api(`/messages?after=${lastQueueID}&timeout=15`, {signal: pollController.signal}); + if (data.last_id) lastQueueID = data.last_id; + + for (const msg of (data.messages || [])) { + handleMessage(msg); + } + } catch(e) { + if (e instanceof DOMException && e.name === 'AbortError') continue; + if (e.status === 401) { + localStorage.removeItem('chat_token'); + location.reload(); + return; + } + await new Promise(r => setTimeout(r, 2000)); + } + } +} + +function handleMessage(msg) { + const body = Array.isArray(msg.body) ? msg.body : []; + const bodyText = body.join('\n'); + + switch (msg.command) { + case 'PRIVMSG': + case 'NOTICE': { + let target = msg.to; + // DM: if it's to me, show under sender's nick tab + if (!target.startsWith('#')) { + target = msg.from === myNick ? msg.to : msg.from; + if (!messages[target]) messages[target] = []; + } + addMessage(target, msg); + break; + } + case 'JOIN': { + addMessage(msg.to, msg); + if (msg.from === myNick) { + // We joined a channel + if (!channels.find(c => c.name === msg.to)) { + channels.push({name: msg.to, topic: ''}); + } + switchTab(msg.to); + fetchMembers(msg.to); + } else if (activeTab === msg.to) { + fetchMembers(msg.to); + } + break; + } + case 'PART': { + addMessage(msg.to, msg); + if (msg.from === myNick) { + channels = channels.filter(c => c.name !== msg.to); + if (activeTab === msg.to) switchTab('server'); + else renderTabs(); + } else if (activeTab === msg.to) { + fetchMembers(msg.to); + } + break; + } + case 'QUIT': { + // Show in all channels where this user might be + channels.forEach(ch => { + addMessage(ch.name, msg); + }); + break; + } + case 'NICK': { + const newNick = body[0] || ''; + if (msg.from === myNick) { + myNick = newNick; + addSystemMessage(activeTab || 'server', `You are now known as ${newNick}`); + } else { + channels.forEach(ch => { + addMessage(ch.name, msg); + }); + } + break; + } + case 'TOPIC': { + addMessage(msg.to, msg); + const ch = channels.find(c => c.name === msg.to); + if (ch) ch.topic = bodyText; + break; + } + default: + addSystemMessage('server', `[${msg.command}] ${bodyText}`); + } +} + +// --- Login --- + +function renderLogin() { + const root = $('#root'); + root.innerHTML = ''; + + let serverName = 'Chat'; + let motd = ''; + + api('/server').then(data => { + if (data.name) { serverName = data.name; $('h1', root).textContent = serverName; } + if (data.motd) { motd = data.motd; const m = $('.motd', root); if(m) m.textContent = motd; } + }).catch(() => {}); + + const form = el('form', {class:'login-screen', onSubmit: async (e) => { + e.preventDefault(); + const nick = $('input', form).value.trim(); + if (!nick) return; + const errEl = $('.error', form); + if (errEl) errEl.textContent = ''; + try { + const data = await api('/session', {method:'POST', body: JSON.stringify({nick})}); + token = data.token; + myNick = data.nick; + myUID = data.id; + localStorage.setItem('chat_token', token); + startApp(); + } catch(err) { + const errEl = $('.error', form) || form.appendChild(el('div', {class:'error'})); + errEl.textContent = err.data?.error || 'Connection failed'; + } + }}, + el('h1', null, serverName), + motd ? el('div', {class:'motd'}, motd) : null, + el('input', {type:'text', placeholder:'Choose a nickname...', maxLength:'32', autofocus:'true'}), + el('button', {type:'submit'}, 'Connect'), + el('div', {class:'error'}) + ); + root.appendChild(form); + $('input', form)?.focus(); +} + +async function startApp() { + messages = {server: []}; + unread = {}; + channels = []; + activeTab = 'server'; + lastQueueID = 0; + + addSystemMessage('server', `Connected as ${myNick}`); + + // Fetch server info + try { + const info = await api('/server'); + if (info.motd) addSystemMessage('server', `MOTD: ${info.motd}`); + } catch(e) {} + + // Fetch current state (channels we're already in) + try { + const state = await api('/state'); + myNick = state.nick; + myUID = state.id; + if (state.channels) { + state.channels.forEach(ch => { + channels.push({name: ch.name, topic: ch.topic}); + if (!messages[ch.name]) messages[ch.name] = []; + }); + if (channels.length > 0) switchTab(channels[0].name); + } + } catch(e) {} + + renderApp(); + pollLoop(); +} + +// --- Init --- + +if (token) { + // Try to resume session + api('/state').then(data => { + myNick = data.nick; + myUID = data.id; + startApp(); + }).catch(() => { + localStorage.removeItem('chat_token'); + token = null; + renderLogin(); + }); +} else { + renderLogin(); +} + +})();