2 Commits

Author SHA1 Message Date
clawbot
80f17dc335 fix: correct CHANMODES ISUPPORT classification and deduplicate MintChannelHashcash
Some checks failed
check / check (push) Failing after 1m51s
- Move H from Type B to Type C in CHANMODES ISUPPORT string (H takes
  a parameter only when set, not when unset)
- Refactor MintChannelHashcash to delegate to hashcash.BodyHash() and
  hashcash.MintChannelStamp() instead of reimplementing them
2026-03-17 02:52:09 -07:00
user
3d285f1b66 feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam
All checks were successful
check / check (push) Successful in 2m18s
Add per-channel hashcash requirement via MODE +H <bits>. When set,
PRIVMSG to the channel must include a valid hashcash stamp in the
meta.hashcash field bound to the channel name and message body hash.

Server validates stamp format, difficulty, date freshness, channel
binding, body hash binding, and proof-of-work. Spent stamps are
persisted to SQLite with 1-year TTL for replay prevention.

Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter

Changes:
- Schema: add hashcash_bits column to channels, spent_hashcash table
- DB: queries for get/set channel hashcash bits, spent token CRUD
- Hashcash: ChannelValidator, BodyHash, StampHash, MintChannelStamp
- Handlers: validate hashcash on PRIVMSG, MODE +H/-H support
- Pass meta through fanOut chain to store in messages
- Prune spent hashcash tokens in cleanup loop (1-year TTL)
- Client: MintChannelHashcash helper for CLI
- Tests: 12 new channel_test.go + 10 new api_test.go integration tests
- README: document +H mode, stamp format, and usage
2026-03-17 02:37:14 -07:00
19 changed files with 602 additions and 1261 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,21 +7,10 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
# ensure-web-dist creates placeholder files so //go:embed dist/* in
# web/embed.go resolves without a full Node.js build. The real SPA is
# built by the web-builder Docker stage; these placeholders let
# "make test" and "make build" work outside Docker.
ensure-web-dist:
@if [ ! -d web/dist ]; then \
mkdir -p web/dist && \
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
echo "==> Created placeholder web/dist/ for go:embed"; \
fi
build: ensure-web-dist
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: ensure-web-dist
lint:
golangci-lint run --config .golangci.yml ./...
fmt:
@@ -31,7 +20,7 @@ fmt:
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: ensure-web-dist
test:
go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes

522
README.md
View File

@@ -113,9 +113,8 @@ mechanisms or stuffing data into CTCP.
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. Identity starts with a key a nick is a display name.
Accounts are optional: you can create an anonymous session instantly, or
set a password via the PASS command for multi-client access to a single session.
are unique per server. There are no accounts — identity is a key, a nick is a
display name.
### On the resemblance to JSON-RPC
@@ -149,59 +148,28 @@ 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 — Cookie-Based Authentication
### Identity & Sessions — No Accounts
The server uses **HTTP cookies** for all authentication. There is no separate
registration step — sessions start anonymous and can optionally set a password
for multi-client access.
#### Session Creation
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 sends `POST /api/v1/session` with a desired
nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
a cryptographically random token (64 hex characters) and returns the user
ID and nick in the JSON response body. No token appears in the JSON body.
- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS.
Clients never need to handle the token directly — the browser/HTTP client
manages cookies automatically.
- Sessions start anonymous — no password required. When the session expires
or the user QUITs, the nick is released.
#### Setting a Password (Optional, for Multi-Client Access)
For users who want to access the same session from multiple devices:
- **Set password via IRC PASS command**: the authenticated client sends
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
The server hashes the password with bcrypt and stores it on the session.
Password must be at least 8 characters.
- **Login from another client**: `POST /api/v1/login` with nick and password →
server verifies the password, creates a new client for the existing session,
and sets an auth cookie. Channel memberships and message queues are shared.
Login only works while the session still exists — if all clients have logged
out or the user has sent QUIT, the session is deleted and the password is
lost.
#### Common Properties
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 <token>`.
- 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.
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
encoded in the token, no client-side decode. The server is the sole authority
on cookie validity.
- 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.
Anonymous sessions preserve that simplicity — instant access, zero friction.
But some users want to access the same session from multiple devices without
a bouncer. The PASS command enables multi-client login without adding friction
for casual users: if you don't need multi-client, just create a session and
go. Cookie-based auth eliminates token management from client code entirely —
browsers and HTTP cookie jars handle it automatically. Note: both anonymous
and password-protected sessions are deleted when the last client disconnects
(QUIT or logout). Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent of
password status.
**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
@@ -228,7 +196,7 @@ A single user session can have multiple clients (phone, laptop, terminal).
- The server fans out all S2C messages to every active client queue for that
user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth cookie.
identified by the auth token.
- Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others.
@@ -239,11 +207,12 @@ User Session
└── Client C (token_c, queue_c)
```
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
client to an existing session (one that has a password set via PASS command),
enabling true multi-client support (multiple cookies sharing one nick/session
with independent message queues). Sessions without a password cannot be
logged into.
**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
@@ -296,8 +265,8 @@ The server implements HTTP long-polling for real-time message delivery:
- The client disconnects (connection closed, no response needed)
**Implementation detail:** The server maintains an in-memory broker with
per-client notification channels. When a message is enqueued for a client, the
broker closes all waiting channels for that client, waking up any blocked
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.
@@ -350,22 +319,19 @@ 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 Cookies Instead of JWTs
### 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 auth cookies are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
sets raw hex as an HttpOnly cookie
- On each request, server hashes the cookie value and looks it up
- Revocation is a database delete (cookie becomes invalid immediately)
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
- Cookie format can change without breaking clients
- Clients never handle tokens directly — browsers and HTTP cookie jars
manage everything automatically
- Token format can change without breaking clients
---
@@ -389,18 +355,15 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle
#### Session Creation
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
│ │
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │
│ (Cookie sent automatically on all subsequent requests)
│ (Server fans out JOIN event to all #general members)
│ │
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │
@@ -417,37 +380,7 @@ The entire read/write loop for a client is two endpoints. Everything else
│ 6. POST /api/v1/messages {"command":"QUIT"} │
│ → {"status":"quit"} │
│ (Server broadcasts QUIT, removes from channels, │
│ deletes session, releases nick, clears cookie)
│ │
└────────────────────────────────────────────────────────────┘
```
#### Multi-Client via Password
```
┌─ Client A ────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │
│ 2. POST /api/v1/messages │
│ {"command":"PASS","body":["s3cret!!"]} │
│ → {"status":"ok"} │
│ (Password set via IRC PASS command) │
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
└────────────────────────────────────────────────────────────┘
┌─ Client B (another device, while session is still active) ┐
│ │
│ 3. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ (New client added to existing session — channels │
│ and message queues are preserved.) │
│ deletes session, releases nick)
│ │
└────────────────────────────────────────────────────────────┘
```
@@ -465,28 +398,28 @@ The entire read/write loop for a client is two endpoints. Everything else
│ │ │
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
│client_queue│ │client_queue│ │client_queue│
client_id=1│ │ client_id=2│ │ client_id=3│
user_id=1 │ │ user_id=2 │ │ user_id=3
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │
└────────────┘ └────────────┘ └────────────┘
alice bob carol
Each message is stored ONCE. One queue entry per recipient client.
Each message is stored ONCE. One queue entry per recipient.
```
The `client_queues` table contains `(client_id, message_id)` pairs. When a
The `client_queues` table contains `(user_id, message_id)` pairs. When a
client polls with `GET /messages?after=<queue_id>`, the server queries for
queue entries with `id > after` for that client, joins against the messages
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.
### In-Memory Broker
The server maintains an in-memory notification broker to avoid database
polling. The broker is a map of `client_id → []chan struct{}`. When a message
is enqueued for a client:
polling. The broker is a map of `user_id → []chan struct{}`. When a message
is enqueued for a user:
1. The handler calls `broker.Notify(clientID)`
2. The broker closes all waiting channels for that client
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
@@ -734,35 +667,6 @@ Change the user's nickname.
**IRC reference:** RFC 1459 §4.1.2
#### PASS — Set Session Password
Set a password on the current session, enabling multi-client login via
`POST /api/v1/login`. The password is hashed with bcrypt and stored
server-side.
**C2S:**
```json
{"command": "PASS", "body": ["mypassword"]}
```
**Behavior:**
- `body[0]` is the password. Must be at least 8 characters.
- On success, the server responds with `{"status": "ok"}`.
- If the password is too short or missing, the server sends
ERR_NEEDMOREPARAMS (461) via the message queue.
- Calling PASS again overwrites the previous password.
- Once a password is set, `POST /api/v1/login` can be used with the nick
and password to create additional clients on the same session.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
session password management.
#### TOPIC — Set Channel Topic
Set or change a channel's topic.
@@ -829,7 +733,7 @@ Destroy the session and disconnect from the server.
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 cookie is invalidated, the nick
- The user's session is destroyed — the auth token is invalidated, the nick
is released.
- Subsequent requests with the old token return HTTP 401.
@@ -1120,8 +1024,8 @@ to compute stamps. Higher bit counts take exponentially longer to compute.
## API Reference
All endpoints accept and return `application/json`. Authenticated endpoints
require the `neoirc_auth` cookie, which is set automatically by
`POST /api/v1/session` and `POST /api/v1/login`.
require `Authorization: Bearer <token>` header. The token is obtained from
`POST /api/v1/session`.
All API responses include appropriate HTTP status codes. Error responses have
the format:
@@ -1150,18 +1054,11 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created`
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
The JSON body does **not** include the token.
```
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
```
```json
{
"id": 1,
"nick": "alice"
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
```
@@ -1169,16 +1066,7 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick (always matches request on success) |
**Cookie properties:**
| Property | Value |
|------------|-------|
| `Name` | `neoirc_auth` |
| `HttpOnly` | `true` (not accessible from JavaScript) |
| `SameSite` | `Strict` (prevents CSRF) |
| `Secure` | `true` when behind TLS |
| `Path` | `/` |
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
**Errors:**
@@ -1191,61 +1079,10 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
**curl example:**
```bash
# Use -c to save cookies, -b to send them
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
```
### POST /api/v1/login — Login to Account
Authenticate with a nick and password (set via the PASS IRC command). Creates a
new client for the existing session, preserving channel memberships and message
queues. This is how multi-client access works: each login adds a new client to
the session with its own auth cookie and message delivery queue.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
new client's queue, so the client can immediately restore its UI state.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match an active session with a password set |
| `password` | string | Yes | Must match the session's password |
**Response:** `200 OK`
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
```json
{
"id": 1,
"nick": "alice"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID |
| `nick` | string | Current nick |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
**curl example:**
```bash
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}'
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
echo $TOKEN
```
### GET /api/v1/state — Get Session State
@@ -1289,13 +1126,13 @@ Each channel object:
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/state \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1355,12 +1192,14 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):**
```bash
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
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 -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
```
### POST /api/v1/messages — Send Command
@@ -1387,7 +1226,6 @@ reference with all required and optional fields.
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK |
@@ -1402,14 +1240,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth cookies (401), and server errors (500).
auth tokens (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth cookie |
| 401 | `unauthorized` | Missing or invalid auth token |
| 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):**
@@ -1500,11 +1338,11 @@ events). Event messages are delivered via the live queue only.
```bash
# Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
# Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/channels — List Channels
@@ -1535,22 +1373,18 @@ List members of a channel. The `{name}` parameter is the channel name
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/channels/general/members \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### POST /api/v1/logout — Logout
Destroy the current client's session cookie and server-side client record.
If no other clients remain on the session, the user is fully cleaned up:
parted from all channels (with QUIT broadcast to members), session deleted,
nick released. The auth cookie is cleared in the response.
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
**Request:** No body. Requires auth cookie.
**Request:** No body. Requires auth.
**Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json
{"status": "ok"}
```
@@ -1559,11 +1393,12 @@ The response clears the `neoirc_auth` cookie.
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth cookie |
| 401 | `unauthorized` | Missing or invalid auth token |
**curl example:**
```bash
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/users/me — Current User Info
@@ -1587,7 +1422,7 @@ Return the current user's session state. This is an alias for
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/server — Server Info
@@ -1615,40 +1450,13 @@ Return server metadata. No authentication required.
### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required. Returns server
health status and runtime statistics.
Standard health check endpoint. No authentication required.
**Response:** `200 OK`
```json
{
"status": "ok",
"now": "2024-01-15T12:00:00.000000000Z",
"uptimeSeconds": 3600,
"uptimeHuman": "1h0m0s",
"version": "0.1.0",
"appname": "neoirc",
"maintenanceMode": false,
"sessions": 42,
"clients": 85,
"queuedLines": 128,
"channels": 7,
"connectionsSinceBoot": 200,
"sessionsSinceBoot": 150,
"messagesSinceBoot": 5000
}
{"status": "ok"}
```
| Field | Description |
| ---------------------- | ------------------------------------------------- |
| `sessions` | Current number of active sessions |
| `clients` | Current number of connected clients |
| `queuedLines` | Total entries in client output queues |
| `channels` | Current number of channels |
| `connectionsSinceBoot` | Total client connections since server start |
| `sessionsSinceBoot` | Total sessions created since server start |
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
---
## Message Flow
@@ -1832,21 +1640,13 @@ authenticity.
### Authentication
- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
entropy). Tokens are hashed (SHA-256) before storage and validated on every
request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict
(CSRF protection), and Secure when behind TLS.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
password, instant access. The auth cookie is the sole credential.
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
password on the session. `POST /api/v1/login` authenticates against the
stored hash and issues a new client cookie.
- **Password security**: Passwords are never stored in plain text. bcrypt
handles salting and key stretching automatically. Sessions without a
password cannot be logged into via `/login`.
- **Cookie security**: Auth cookies should only be transmitted over HTTPS in
production. If a cookie is compromised, the attacker has full access to the
session until QUIT or expiry.
- **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
@@ -1883,10 +1683,8 @@ authenticity.
- **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 with credentials
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
This enables cookie-based auth from cross-origin clients. Restrict origins
in production via reverse proxy configuration if needed.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
@@ -1993,52 +1791,33 @@ The database schema is managed via embedded SQL migration files in
**Current tables:**
#### `sessions`
#### `users`
| Column | Type | Description |
|-----------------|----------|-------------|
|-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) |
| `token` | TEXT | Unique auth token (64 hex chars) |
| `created_at`| DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
Index on `(uuid)`.
#### `clients`
| Column | Type | Description |
|--------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
| `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time |
Indexes on `(token)` and `(session_id)`.
#### `channels`
| Column | Type | Description |
|---------------|----------|-------------|
|-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `name` | TEXT | Unique channel name (e.g., `#general`) |
| `topic` | TEXT | Channel topic (default empty) |
| `topic_set_by`| TEXT | Nick of the user who set the topic (default empty) |
| `topic_set_at`| DATETIME | When the topic was last set |
| `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 (cascade delete) |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `channel_id`| INTEGER | FK → channels.id |
| `user_id` | INTEGER | FK → users.id |
| `joined_at` | DATETIME | When the user joined |
Unique constraint on `(channel_id, session_id)`.
Unique constraint on `(channel_id, user_id)`.
#### `messages`
| Column | Type | Description |
@@ -2048,7 +1827,6 @@ Unique constraint on `(channel_id, session_id)`.
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
| `msg_from` | TEXT | Sender nick |
| `msg_to` | TEXT | Target (`#channel` or nick) |
| `params` | TEXT | JSON-encoded IRC-style positional parameters |
| `body` | TEXT | JSON-encoded body (array or object) |
| `meta` | TEXT | JSON-encoded metadata |
| `created_at`| DATETIME | Server timestamp |
@@ -2059,11 +1837,11 @@ Indexes on `(msg_to, id)` and `(created_at)`.
| Column | Type | Description |
|-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
| `client_id` | INTEGER | FK → clients.id (cascade delete) |
| `message_id`| INTEGER | FK → messages.id (cascade delete) |
| `user_id` | INTEGER | FK → users.id |
| `message_id`| INTEGER | FK → messages.id |
| `created_at`| DATETIME | When the entry was queued |
Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`.
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=<id>`. This is more reliable than timestamps (no clock
@@ -2076,19 +1854,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
or when the last client logs out (`POST /api/v1/logout` with no remaining
clients triggers session cleanup). There is no distinction between session
types in the cleanup path — `handleQuit` and `cleanupUser` both call
`DeleteSession` unconditionally. Idle sessions are automatically expired
after `SESSION_IDLE_TIMEOUT`
(default 30 days) — the server runs a background cleanup loop that parts
idle users from all channels, broadcasts QUIT, and releases their nicks.
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
A session can have multiple clients; removing one doesn't affect others.
However, when the last client is removed (via logout), the entire session
is deleted — the user is parted from all channels, QUIT is broadcast, and
the nick is released.
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
30 days) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
---
@@ -2237,59 +2006,58 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl
```bash
# 1a. Create a session (cookie saved automatically with -c)
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
# 1. Create a session
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser"}'
# 1b. Optionally set a password for multi-client access
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PASS","body":["mypassword"]}'
# 1c. Login from another device (saves new cookie)
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}'
-d '{"nick":"testuser"}' | jq -r .token)
# 2. Join a channel
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"JOIN","to":"#general"}'
# 3. Send a message
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
# 4. Poll for messages (one-shot)
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
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 -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
# 6. Send a DM
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
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 -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
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 -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
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 -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
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 -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"QUIT","body":["leaving"]}'
```
@@ -2299,25 +2067,27 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
The key to real-time messaging is the poll loop. Here's the pattern:
```python
# Python example — using requests.Session for automatic cookie handling
import requests, json, time
# Python example
import requests, json
BASE = "http://localhost:8080/api/v1"
session = requests.Session() # Manages cookies automatically
token = None
last_id = 0
# Create session (cookie set automatically via Set-Cookie header)
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
print(f"Session: {resp.json()}")
# Create session
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Join channel
session.post(f"{BASE}/messages",
requests.post(f"{BASE}/messages", headers=headers,
json={"command": "JOIN", "to": "#general"})
# Poll loop
while True:
try:
resp = session.get(f"{BASE}/messages",
resp = requests.get(f"{BASE}/messages",
headers=headers,
params={"after": last_id, "timeout": 15},
timeout=20) # HTTP timeout > long-poll timeout
data = resp.json()
@@ -2334,14 +2104,14 @@ while True:
```
```javascript
// JavaScript/browser example — cookies sent automatically
async function pollLoop() {
// JavaScript/browser example
async function pollLoop(token) {
let lastId = 0;
while (true) {
try {
const resp = await fetch(
`/api/v1/messages?after=${lastId}&timeout=15`,
{credentials: 'same-origin'} // Include cookies
{headers: {'Authorization': `Bearer ${token}`}}
);
if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json();
@@ -2373,11 +2143,9 @@ Clients should handle these message commands from the queue:
### Error Handling
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
re-login (if a password was set).
- **HTTP 401**: Token expired or invalid. Re-create session.
- **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or
NICK change).
- **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).
@@ -2394,11 +2162,8 @@ Clients should handle these message commands from the queue:
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 auth cookie is
invalid. For sessions without a password, create a new session. For
sessions with a password set (via PASS command), log in again via
`POST /api/v1/login` to get a fresh cookie on the same session. If it
fails with a network error, retry with backoff.
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.
---
@@ -2557,10 +2322,8 @@ creating one session pays once and keeps their session.
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [x] **Multi-client sessions** — set a password via PASS command, then
login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication
- [ ] **Multi-client sessions** — add client to existing session
(share nick across devices)
### Future (1.0+)
@@ -2620,8 +2383,6 @@ neoirc/
│ │ └── healthcheck.go # Health check handler
│ ├── healthcheck/ # Health check logic
│ │ └── healthcheck.go
│ ├── stats/ # Runtime statistics (atomic counters)
│ │ └── stats.go
│ ├── logger/ # slog-based logging
│ │ └── logger.go
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
@@ -2673,12 +2434,9 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the
API is too complex.
2. **Passwords optional** — anonymous sessions are instant: pick a nick and
talk. No registration, no email verification. The cost of entry is a
hashcash proof, not bureaucracy. For users who want multi-client access
(multiple devices sharing one session), the PASS command sets a password
on the session — but it's never required. Identity verification at the
message layer uses cryptographic signing, independent of password status.
2. **No accounts** — identity is a signing key, nick is a display name. No
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

View File

@@ -10,7 +10,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -36,7 +35,6 @@ func main() {
server.New,
middleware.New,
healthcheck.New,
stats.New,
),
fx.Invoke(func(*server.Server) {}),
).Run()

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
@@ -29,19 +28,16 @@ var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient creates a new API client with a cookie jar
// for automatic auth cookie management.
// NewClient creates a new API client.
func NewClient(baseURL string) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
return &Client{ //nolint:exhaustruct // Token set after CreateSession
BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout,
Jar: jar,
},
}
}
@@ -83,6 +79,8 @@ func (client *Client) CreateSession(
return nil, fmt.Errorf("decode session: %w", err)
}
client.Token = resp.Token
return &resp, nil
}
@@ -123,7 +121,6 @@ func (client *Client) PollMessages(
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
Jar: client.HTTPClient.Jar,
}
params := url.Values{}
@@ -148,6 +145,10 @@ func (client *Client) PollMessages(
return nil, fmt.Errorf("new request: %w", err)
}
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
resp, err := pollClient.Do(request)
if err != nil {
return nil, fmt.Errorf("poll request: %w", err)
@@ -303,6 +304,12 @@ func (client *Client) do(
"Content-Type", "application/json",
)
if client.Token != "" {
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)

View File

@@ -12,6 +12,7 @@ type SessionRequest struct {
type SessionResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Token string `json:"token"`
}
// StateResponse is the response from GET /api/v1/state.

View File

@@ -16,28 +16,80 @@ var errNoPassword = errors.New(
"account has no password set",
)
// SetPassword sets a bcrypt-hashed password on a session,
// enabling multi-client login via POST /api/v1/login.
func (database *Database) SetPassword(
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
ctx context.Context,
sessionID int64,
password string,
) error {
nick, password string,
) (int64, int64, string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return fmt.Errorf("hash password: %w", err)
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
}
_, err = database.conn.ExecContext(ctx,
"UPDATE sessions SET password_hash = ? WHERE id = ?",
string(hash), sessionID)
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return fmt.Errorf("set password: %w", err)
return 0, 0, "", err
}
return nil
now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
}
// LoginUser verifies a nick/password and creates a new

View File

@@ -6,65 +6,63 @@ import (
_ "modernc.org/sqlite"
)
func TestSetPassword(t *testing.T) {
func TestRegisterUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "passuser")
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "password123",
)
if err != nil {
t.Fatal(err)
}
// Verify we can now log in with the password.
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "passuser", "password123")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || loginToken == "" {
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
func TestSetPasswordThenWrongLogin(t *testing.T) {
if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "wrongpw")
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "correctpass",
)
if err != nil {
t.Fatal(err)
_ = regSID
_ = regCID
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
_ = dupSID
_ = dupCID
_ = dupToken
}
func TestLoginUser(t *testing.T) {
@@ -73,26 +71,23 @@ func TestLoginUser(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "loginuser")
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "mypassword",
)
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, token, err :=
sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || token == "" {
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
@@ -108,6 +103,33 @@ func TestLoginUser(t *testing.T) {
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) {
t.Parallel()

View File

@@ -1267,45 +1267,6 @@ func (database *Database) PruneOldMessages(
return deleted, nil
}
// GetClientCount returns the total number of clients.
func (database *Database) GetClientCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM clients",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get client count: %w", err,
)
}
return count, nil
}
// GetQueueEntryCount returns the total number of entries
// in the client output queues.
func (database *Database) GetQueueEntryCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM client_queues",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get queue entry count: %w", err,
)
}
return count, nil
}
// GetChannelHashcashBits returns the hashcash difficulty
// requirement for a channel. Returns 0 if not set.
func (database *Database) GetChannelHashcashBits(

View File

@@ -36,7 +36,6 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
authCookieName = "neoirc_auth"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -47,18 +46,23 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// authSession extracts the session from the auth cookie.
// authSession extracts the session from the client token.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, int64, string, error) {
cookie, err := request.Cookie(authCookieName)
if err != nil || cookie.Value == "" {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, 0, "", errUnauthorized
}
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), cookie.Value,
request.Context(), token,
)
if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -67,46 +71,6 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil
}
// setAuthCookie sets the authentication cookie on the
// response.
func (hdlr *Handlers) setAuthCookie(
writer http.ResponseWriter,
request *http.Request,
token string,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookie removes the authentication cookie from
// the client.
func (hdlr *Handlers) clearAuthCookie(
writer http.ResponseWriter,
request *http.Request,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter,
request *http.Request,
@@ -257,16 +221,12 @@ func (hdlr *Handlers) handleCreateSession(
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
@@ -912,11 +872,6 @@ func (hdlr *Handlers) dispatchCommand(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
@@ -1033,8 +988,6 @@ func (hdlr *Handlers) handlePrivmsg(
return
}
hdlr.stats.IncrMessages()
if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg(
writer, request,
@@ -1860,32 +1813,6 @@ func (hdlr *Handlers) handleTopic(
return
}
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"check membership failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick, []string{channel},
"You're not on that channel",
)
return
}
hdlr.executeTopic(
writer, request,
sessionID, clientID, nick,
@@ -2047,8 +1974,6 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID,
)
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"},
http.StatusOK)
@@ -2851,8 +2776,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
)
}
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)

View File

@@ -27,7 +27,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
@@ -42,7 +41,6 @@ const (
apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session"
apiState = "/api/v1/state"
authCookieName = "neoirc_auth"
)
// testServer wraps a test HTTP server with helpers.
@@ -93,7 +91,6 @@ func newTestServer(
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
@@ -148,14 +145,12 @@ func newTestHealthcheck(
cfg *config.Config,
log *logger.Logger,
database *db.Database,
tracker *stats.Tracker,
) (*healthcheck.Healthcheck, error) {
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
Globals: globs,
Config: cfg,
Logger: log,
Database: database,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test healthcheck: %w", err)
@@ -189,7 +184,6 @@ func newTestHandlers(
cfg *config.Config,
database *db.Database,
hcheck *healthcheck.Healthcheck,
tracker *stats.Tracker,
) (*handlers.Handlers, error) {
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log,
@@ -197,7 +191,6 @@ func newTestHandlers(
Config: cfg,
Database: database,
Healthcheck: hcheck,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test handlers: %w", err)
@@ -262,7 +255,7 @@ func doRequest(
func doRequestAuth(
t *testing.T,
method, url, cookie string,
method, url, token string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
@@ -280,11 +273,10 @@ func doRequestAuth(
)
}
if cookie != "" {
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
Name: authCookieName,
Value: cookie,
})
if token != "" {
request.Header.Set(
"Authorization", "Bearer "+token,
)
}
resp, err := http.DefaultClient.Do(request)
@@ -327,19 +319,17 @@ func (tserver *testServer) createSession(
)
}
// Drain the body.
_, _ = io.ReadAll(resp.Body)
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
var result struct {
ID int64 `json:"id"`
Token string `json:"token"`
}
tserver.t.Fatal("no auth cookie in response")
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
tserver.t.Fatalf("decode session: %v", decErr)
}
return ""
return result.Token
}
func (tserver *testServer) sendCommand(
@@ -496,10 +486,10 @@ func findNumeric(
func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t)
cookie := tserver.createSession("alice")
token := tserver.createSession("alice")
if cookie == "" {
t.Fatal("expected auth cookie")
if token == "" {
t.Fatal("expected token")
}
}
@@ -621,7 +611,7 @@ func TestCreateSessionMalformed(t *testing.T) {
}
}
func TestAuthNoCookie(t *testing.T) {
func TestAuthNoHeader(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState("")
@@ -630,11 +620,11 @@ func TestAuthNoCookie(t *testing.T) {
}
}
func TestAuthBadCookie(t *testing.T) {
func TestAuthBadToken(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState(
"invalid-cookie-12345",
"invalid-token-12345",
)
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
@@ -1145,42 +1135,6 @@ func TestTopicMissingBody(t *testing.T) {
}
}
func TestTopicNonMember(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_topic")
bobToken := tserver.createSession("bob_topic")
// Only alice joins the channel.
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#topicpriv",
})
// Drain bob's initial messages.
_, lastID := tserver.pollMessages(bobToken, 0)
// Bob tries to set topic without joining.
status, _ := tserver.sendCommand(
bobToken,
map[string]any{
commandKey: "TOPIC",
toKey: "#topicpriv",
bodyKey: []string{"Hijacked topic"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
}
func TestPing(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("ping_user")
@@ -1704,14 +1658,21 @@ func TestHealthcheck(t *testing.T) {
}
}
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
@@ -1719,115 +1680,65 @@ func TestHealthcheckRuntimeStatsFields(t *testing.T) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
"expected 201, got %d: %s",
resp.StatusCode, respBody,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
_ = json.NewDecoder(resp.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
requiredFields := []string{
"sessions", "clients", "queuedLines",
"channels", "connectionsSinceBoot",
"sessionsSinceBoot", "messagesSinceBoot",
}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf(
"missing field %q in healthcheck", field,
if result["nick"] != "reguser" {
t.Fatalf(
"expected reguser, got %v", result["nick"],
)
}
}
}
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
func TestRegisterDuplicate(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("statsuser")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#statschan",
body, err := json.Marshal(map[string]string{
"nick": "dupuser", "password": "password123",
})
tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "#statschan",
bodyKey: []string{"hello stats"},
})
result := tserver.fetchHealthcheck(t)
assertFieldGTE(t, result, "sessions", 1)
assertFieldGTE(t, result, "clients", 1)
assertFieldGTE(t, result, "channels", 1)
assertFieldGTE(t, result, "queuedLines", 0)
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
assertFieldGTE(t, result, "messagesSinceBoot", 1)
if err != nil {
t.Fatal(err)
}
func (tserver *testServer) fetchHealthcheck(
t *testing.T,
) map[string]any {
t.Helper()
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
var result map[string]any
defer func() { _ = resp2.Body.Close() }()
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
return result
}
func assertFieldGTE(
t *testing.T,
result map[string]any,
field string,
minimum float64,
) {
t.Helper()
val, ok := result[field].(float64)
if !ok {
t.Errorf(
"field %q: not a number (got %T)",
field, result[field],
)
return
}
if val < minimum {
t.Errorf(
"expected %s >= %v, got %v",
field, minimum, val,
)
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp2.StatusCode)
}
}
@@ -1865,102 +1776,36 @@ func postJSONExpectStatus(
}
}
func TestPassCommand(t *testing.T) {
func TestRegisterShortPassword(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("passuser")
// Drain initial messages.
_, _ = tserver.pollMessages(token, 0)
// Set password via PASS command.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"s3cure_pass"},
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "shortpw", "password": "short",
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
http.StatusBadRequest,
)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok, got %v", result[statusKey],
)
}
}
func TestPassCommandShortPassword(t *testing.T) {
func TestRegisterInvalidNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("shortpw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try short password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"short"},
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "bad nick!",
"password": "password123",
},
http.StatusBadRequest,
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestPassCommandEmpty(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptypw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try empty password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{commandKey: "PASS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"password123"},
})
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
// Register first.
regBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
@@ -1968,6 +1813,26 @@ func TestLoginValid(t *testing.T) {
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
@@ -1977,33 +1842,31 @@ func TestLoginValid(t *testing.T) {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
defer func() { _ = resp2.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp.StatusCode, respBody,
resp2.StatusCode, respBody,
)
}
// Extract auth cookie from login response.
var loginCookie string
var result map[string]any
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
loginCookie = cookie.Value
_ = json.NewDecoder(resp2.Body).Decode(&result)
break
}
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if loginCookie == "" {
t.Fatal("expected auth cookie from login")
// Verify token works.
token, ok := result["token"].(string)
if !ok {
t.Fatal("token not a string")
}
// Verify login cookie works for auth.
status, state := tserver.getState(loginCookie)
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
@@ -2019,22 +1882,49 @@ func TestLoginValid(t *testing.T) {
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"correctpass1"},
regBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "correctpass1",
})
if err != nil {
t.Fatal(err)
}
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "wrongpwuser",
"password": "wrongpass12",
},
http.StatusUnauthorized,
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
}
func TestLoginNonexistentUser(t *testing.T) {
@@ -2050,74 +1940,13 @@ func TestLoginNonexistentUser(t *testing.T) {
)
}
func TestSessionCookie(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(
map[string]string{"nick": "cookietest"},
)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
t.Fatalf(
"expected 201, got %d", resp.StatusCode,
)
}
// Verify Set-Cookie header.
var authCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
authCookie = cookie
break
}
}
if authCookie == nil {
t.Fatal("expected neoirc_auth cookie")
}
if !authCookie.HttpOnly {
t.Fatal("cookie should be HttpOnly")
}
if authCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("cookie should be SameSite=Strict")
}
// Verify JSON body does NOT contain token.
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if _, hasToken := result["token"]; hasToken {
t.Fatal("JSON body should not contain token")
}
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected cookie for anonymous session")
t.Fatal("expected token for anonymous session")
}
status, state := tserver.getState(token)

View File

@@ -5,11 +5,117 @@ import (
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"git.eeqj.de/sneak/neoirc/internal/db"
)
const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if db.IsUniqueConstraintError(err) {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
@@ -74,8 +180,6 @@ func (hdlr *Handlers) handleLogin(
return
}
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
@@ -86,66 +190,9 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick,
)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusOK)
}
// handlePass handles the IRC PASS command to set a
// password on the authenticated session, enabling
// multi-client login via POST /api/v1/login.
func (hdlr *Handlers) handlePass(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 || lines[0] == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Not enough parameters",
)
return
}
password := lines[0]
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
)
return
}
err := hdlr.params.Database.SetPassword(
request.Context(), sessionID, password,
)
if err != nil {
hdlr.log.Error(
"set password failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -31,7 +30,6 @@ type Params struct {
Config *config.Config
Database *db.Database
Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
}
const defaultIdleTimeout = 30 * 24 * time.Hour
@@ -49,7 +47,6 @@ type Handlers struct {
broker *broker.Broker
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc
}
@@ -70,7 +67,6 @@ func New(
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
}
lifecycle.Append(fx.Hook{

View File

@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
resp := hdlr.hc.Healthcheck(request.Context())
resp := hdlr.hc.Healthcheck()
hdlr.respondJSON(writer, request, resp, httpStatusOK)
}
}

View File

@@ -10,7 +10,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -22,7 +21,6 @@ type Params struct {
Config *config.Config
Logger *logger.Logger
Database *db.Database
Stats *stats.Tracker
}
// Healthcheck tracks server uptime and provides health status.
@@ -66,22 +64,11 @@ type Response struct {
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"`
// Runtime statistics.
Sessions int64 `json:"sessions"`
Clients int64 `json:"clients"`
QueuedLines int64 `json:"queuedLines"`
Channels int64 `json:"channels"`
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
}
// Healthcheck returns the current health status of the server.
func (hcheck *Healthcheck) Healthcheck(
ctx context.Context,
) *Response {
resp := &Response{
func (hcheck *Healthcheck) Healthcheck() *Response {
return &Response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(hcheck.uptime().Seconds()),
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
Appname: hcheck.params.Globals.Appname,
Version: hcheck.params.Globals.Version,
Maintenance: hcheck.params.Config.MaintenanceMode,
Sessions: 0,
Clients: 0,
QueuedLines: 0,
Channels: 0,
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
}
hcheck.populateDBStats(ctx, resp)
return resp
}
// populateDBStats fills in database-derived counters.
func (hcheck *Healthcheck) populateDBStats(
ctx context.Context,
resp *Response,
) {
sessions, err := hcheck.params.Database.GetUserCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: session count failed",
"error", err,
)
} else {
resp.Sessions = sessions
}
clients, err := hcheck.params.Database.GetClientCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: client count failed",
"error", err,
)
} else {
resp.Clients = clients
}
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: queue entry count failed",
"error", err,
)
} else {
resp.QueuedLines = queued
}
channels, err := hcheck.params.Database.GetChannelCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: channel count failed",
"error", err,
)
} else {
resp.Channels = channels
}
}

View File

@@ -126,23 +126,18 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
}
// CORS returns middleware that handles Cross-Origin Resource Sharing.
// AllowCredentials is true so browsers include cookies in
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowOriginFunc: func(
_ *http.Request, _ string,
) bool {
return true
},
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Content-Type", "X-CSRF-Token",
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
AllowCredentials: false,
MaxAge: corsMaxAge,
})
}

View File

@@ -75,6 +75,10 @@ func (srv *Server) setupAPIv1(router chi.Router) {
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),

View File

@@ -1,52 +0,0 @@
// Package stats tracks runtime statistics since server boot.
package stats
import (
"sync/atomic"
)
// Tracker holds atomic counters for runtime statistics
// that accumulate since the server started.
type Tracker struct {
connectionsSinceBoot atomic.Int64
sessionsSinceBoot atomic.Int64
messagesSinceBoot atomic.Int64
}
// New creates a new Tracker with all counters at zero.
func New() *Tracker {
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
}
// IncrConnections increments the total connection count.
func (t *Tracker) IncrConnections() {
t.connectionsSinceBoot.Add(1)
}
// IncrSessions increments the total session count.
func (t *Tracker) IncrSessions() {
t.sessionsSinceBoot.Add(1)
}
// IncrMessages increments the total PRIVMSG/NOTICE count.
func (t *Tracker) IncrMessages() {
t.messagesSinceBoot.Add(1)
}
// ConnectionsSinceBoot returns the total number of
// client connections since boot.
func (t *Tracker) ConnectionsSinceBoot() int64 {
return t.connectionsSinceBoot.Load()
}
// SessionsSinceBoot returns the total number of sessions
// created since boot.
func (t *Tracker) SessionsSinceBoot() int64 {
return t.sessionsSinceBoot.Load()
}
// MessagesSinceBoot returns the total number of
// PRIVMSG/NOTICE messages sent since boot.
func (t *Tracker) MessagesSinceBoot() int64 {
return t.messagesSinceBoot.Load()
}

View File

@@ -1,117 +0,0 @@
package stats_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/stats"
)
func TestNew(t *testing.T) {
t.Parallel()
tracker := stats.New()
if tracker == nil {
t.Fatal("expected non-nil tracker")
}
if tracker.ConnectionsSinceBoot() != 0 {
t.Errorf(
"expected 0 connections, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 0 {
t.Errorf(
"expected 0 sessions, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 0 {
t.Errorf(
"expected 0 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}
func TestIncrConnections(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrConnections()
tracker.IncrConnections()
got := tracker.ConnectionsSinceBoot()
if got != 3 {
t.Errorf(
"expected 3 connections, got %d", got,
)
}
}
func TestIncrSessions(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrSessions()
tracker.IncrSessions()
got := tracker.SessionsSinceBoot()
if got != 2 {
t.Errorf(
"expected 2 sessions, got %d", got,
)
}
}
func TestIncrMessages(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrMessages()
got := tracker.MessagesSinceBoot()
if got != 1 {
t.Errorf(
"expected 1 message, got %d", got,
)
}
}
func TestCountersAreIndependent(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrSessions()
tracker.IncrMessages()
tracker.IncrMessages()
if tracker.ConnectionsSinceBoot() != 1 {
t.Errorf(
"expected 1 connection, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 1 {
t.Errorf(
"expected 1 session, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 2 {
t.Errorf(
"expected 2 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}

View File

@@ -11,7 +11,6 @@ const (
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"