Compare commits
10 Commits
b0107da216
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9fd0c5c5 | ||
| bf4d63bc4d | |||
| efbd8fe9ff | |||
| e36bd99ef6 | |||
| e9d794764b | |||
| 052674b4ee | |||
| cab5784913 | |||
| 75cecd9803 | |||
| f2e7a6ec85 | |||
| 27df999942 |
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
||||
|
||||
BINARY := neoircd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -7,10 +7,21 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
|
||||
all: check build
|
||||
|
||||
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
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||
|
||||
lint:
|
||||
lint: ensure-web-dist
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
fmt:
|
||||
@@ -20,7 +31,7 @@ fmt:
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||
|
||||
test:
|
||||
test: ensure-web-dist
|
||||
go test -timeout 30s -v -race -cover ./...
|
||||
|
||||
# check runs all validation without making changes
|
||||
|
||||
583
README.md
583
README.md
@@ -113,8 +113,9 @@ 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. There are no accounts — identity is a key, a nick is a
|
||||
display name.
|
||||
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.
|
||||
|
||||
### On the resemblance to JSON-RPC
|
||||
|
||||
@@ -148,28 +149,59 @@ 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
|
||||
### Identity & Sessions — Cookie-Based Authentication
|
||||
|
||||
There are no accounts, no registration, no passwords. Identity is a signing
|
||||
key; a nick is just a display name. The two are decoupled.
|
||||
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
|
||||
|
||||
- **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 <token>`.
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
### Nick Semantics
|
||||
|
||||
@@ -196,7 +228,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 token.
|
||||
identified by the auth cookie.
|
||||
- Client queues have **independent expiry/pruning** — one client going offline
|
||||
doesn't affect others.
|
||||
|
||||
@@ -207,12 +239,11 @@ User Session
|
||||
└── 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.
|
||||
**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.
|
||||
|
||||
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
||||
phone and laptop connected simultaneously without a bouncer. Server-side
|
||||
@@ -265,8 +296,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-user notification channels. When a message is enqueued for a user, the
|
||||
broker closes all waiting channels for that user, waking up any blocked
|
||||
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
|
||||
long-poll handlers. This is O(1) notification — no polling loops, no database
|
||||
scanning.
|
||||
|
||||
@@ -319,19 +350,22 @@ 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
|
||||
### Why Opaque Cookies 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
|
||||
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)
|
||||
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
||||
- Token format can change without breaking clients
|
||||
- Cookie format can change without breaking clients
|
||||
- Clients never handle tokens directly — browsers and HTTP cookie jars
|
||||
manage everything automatically
|
||||
|
||||
---
|
||||
|
||||
@@ -355,15 +389,18 @@ 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"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
|
||||
│ → {"id":1, "nick":"alice"} │
|
||||
│ │
|
||||
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
|
||||
│ → {"status":"joined","channel":"#general"} │
|
||||
│ (Server fans out JOIN event to all #general members) │
|
||||
│ (Cookie sent automatically on all subsequent requests) │
|
||||
│ │
|
||||
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
|
||||
│ "to":"#general","body":["hello"]} │
|
||||
@@ -380,7 +417,37 @@ 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) │
|
||||
│ 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.) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -398,28 +465,28 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
│ │ │
|
||||
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
|
||||
│client_queue│ │client_queue│ │client_queue│
|
||||
│ user_id=1 │ │ user_id=2 │ │ user_id=3 │
|
||||
│ client_id=1│ │ client_id=2│ │ client_id=3│
|
||||
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
alice bob carol
|
||||
|
||||
Each message is stored ONCE. One queue entry per recipient.
|
||||
Each message is stored ONCE. One queue entry per recipient client.
|
||||
```
|
||||
|
||||
The `client_queues` table contains `(user_id, message_id)` pairs. When a
|
||||
The `client_queues` table contains `(client_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 user, joins against the messages
|
||||
queue entries with `id > after` for that client, 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 `user_id → []chan struct{}`. When a message
|
||||
is enqueued for a user:
|
||||
polling. The broker is a map of `client_id → []chan struct{}`. When a message
|
||||
is enqueued for a client:
|
||||
|
||||
1. The handler calls `broker.Notify(userID)`
|
||||
2. The broker closes all waiting channels for that user
|
||||
1. The handler calls `broker.Notify(clientID)`
|
||||
2. The broker closes all waiting channels for that client
|
||||
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
|
||||
@@ -461,7 +528,7 @@ the same JSON envelope:
|
||||
| `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. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. |
|
||||
|
||||
**Important invariants:**
|
||||
|
||||
@@ -667,6 +734,35 @@ 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.
|
||||
@@ -733,7 +829,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 token is invalidated, the nick
|
||||
- The user's session is destroyed — the auth cookie is invalidated, the nick
|
||||
is released.
|
||||
- Subsequent requests with the old token return HTTP 401.
|
||||
|
||||
@@ -951,12 +1047,13 @@ carries IRC-style parameters (e.g., channel name, target nick).
|
||||
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 |
|
||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
|
||||
|
||||
**User channel modes (set per-user per-channel):**
|
||||
|
||||
@@ -967,14 +1064,64 @@ Inspired by IRC, simplified:
|
||||
|
||||
**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.
|
||||
Exception: `+H` (hashcash) is fully enforced — see below.
|
||||
|
||||
### Per-Channel Hashcash (Anti-Spam)
|
||||
|
||||
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||||
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||||
compute a proof-of-work stamp bound to the specific channel and message before
|
||||
sending.
|
||||
|
||||
**Setting the requirement:**
|
||||
|
||||
```
|
||||
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
|
||||
MODE #channel -H — disable hashcash requirement
|
||||
```
|
||||
|
||||
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
|
||||
|
||||
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
|
||||
- `YYMMDD` — current date (prevents old token reuse)
|
||||
- `channel` — channel name (prevents cross-channel reuse)
|
||||
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
|
||||
- `counter` — hex nonce
|
||||
|
||||
**Sending a message to a hashcash-protected channel:**
|
||||
|
||||
Include the hashcash stamp in the `meta` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "PRIVMSG",
|
||||
"to": "#general",
|
||||
"body": ["hello world"],
|
||||
"meta": {
|
||||
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server validation:** The server checks that the stamp is well-formed, meets
|
||||
the required difficulty, is bound to the correct channel and message body, has a
|
||||
recent date, and has not been previously used. Spent stamps are cached for 1
|
||||
year to prevent replay attacks.
|
||||
|
||||
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
||||
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
||||
descriptive reason.
|
||||
|
||||
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
|
||||
to compute stamps. Higher bit counts take exponentially longer to compute.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints accept and return `application/json`. Authenticated endpoints
|
||||
require `Authorization: Bearer <token>` header. The token is obtained from
|
||||
`POST /api/v1/session`.
|
||||
require the `neoirc_auth` cookie, which is set automatically by
|
||||
`POST /api/v1/session` and `POST /api/v1/login`.
|
||||
|
||||
All API responses include appropriate HTTP status codes. Error responses have
|
||||
the format:
|
||||
@@ -1003,11 +1150,18 @@ 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",
|
||||
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
||||
"nick": "alice"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1015,7 +1169,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|---------|---------|-------------|
|
||||
| `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. |
|
||||
|
||||
**Cookie properties:**
|
||||
|
||||
| Property | Value |
|
||||
|------------|-------|
|
||||
| `Name` | `neoirc_auth` |
|
||||
| `HttpOnly` | `true` (not accessible from JavaScript) |
|
||||
| `SameSite` | `Strict` (prevents CSRF) |
|
||||
| `Secure` | `true` when behind TLS |
|
||||
| `Path` | `/` |
|
||||
|
||||
**Errors:**
|
||||
|
||||
@@ -1028,10 +1191,61 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
# Use -c to save cookies, -b to send them
|
||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
-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"}'
|
||||
```
|
||||
|
||||
### GET /api/v1/state — Get Session State
|
||||
@@ -1075,13 +1289,13 @@ Each channel object:
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/state \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
**Reconnect with channel state initialization:**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||
@@ -1141,14 +1355,12 @@ real-time endpoint — clients call it in a loop.
|
||||
|
||||
**curl example (immediate):**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
|
||||
```
|
||||
|
||||
**curl example (long-poll, 15s):**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
|
||||
```
|
||||
|
||||
### POST /api/v1/messages — Send Command
|
||||
@@ -1175,6 +1387,7 @@ 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 |
|
||||
@@ -1189,14 +1402,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 tokens (401), and server errors (500).
|
||||
auth cookies (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 token |
|
||||
| 401 | `unauthorized` | Missing or invalid auth cookie |
|
||||
| 500 | `internal error` | Server-side failure |
|
||||
|
||||
**IRC numeric error replies (delivered via message queue):**
|
||||
@@ -1287,11 +1500,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" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
|
||||
# Older messages (pagination)
|
||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/channels — List Channels
|
||||
@@ -1322,18 +1535,22 @@ 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 \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### POST /api/v1/logout — Logout
|
||||
|
||||
Destroy the current client's auth token. If no other clients remain on the
|
||||
session, the user is fully cleaned up: parted from all channels (with QUIT
|
||||
broadcast to members), session deleted, nick released.
|
||||
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.
|
||||
|
||||
**Request:** No body. Requires auth.
|
||||
**Request:** No body. Requires auth cookie.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
The response clears the `neoirc_auth` cookie.
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
@@ -1342,12 +1559,11 @@ broadcast to members), session deleted, nick released.
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||
| 401 | `unauthorized` | Missing or invalid auth cookie |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/api/v1/logout \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/users/me — Current User Info
|
||||
@@ -1371,7 +1587,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 \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/server — Server Info
|
||||
@@ -1399,13 +1615,40 @@ Return server metadata. No authentication required.
|
||||
|
||||
### GET /.well-known/healthcheck.json — Health Check
|
||||
|
||||
Standard health check endpoint. No authentication required.
|
||||
Standard health check endpoint. No authentication required. Returns server
|
||||
health status and runtime statistics.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
{
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
| 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
|
||||
@@ -1589,13 +1832,21 @@ 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.
|
||||
- **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.
|
||||
|
||||
### Message Integrity
|
||||
|
||||
@@ -1632,8 +1883,10 @@ 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 by default (`Access-Control-Allow-Origin: *`).
|
||||
Restrict this in production via reverse proxy configuration if needed.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -1740,33 +1993,52 @@ The database schema is managed via embedded SQL migration files in
|
||||
|
||||
**Current tables:**
|
||||
|
||||
#### `users`
|
||||
#### `sessions`
|
||||
| Column | Type | Description |
|
||||
|-------------|----------|-------------|
|
||||
|-----------------|----------|-------------|
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique session UUID |
|
||||
| `nick` | TEXT | Unique nick |
|
||||
| `token` | TEXT | Unique auth token (64 hex chars) |
|
||||
| `created_at`| DATETIME | Session creation time |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `created_at`| DATETIME | Channel creation time |
|
||||
| `updated_at`| DATETIME | Last modification time |
|
||||
| `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 |
|
||||
| `user_id` | INTEGER | FK → users.id |
|
||||
| `channel_id` | INTEGER | FK → channels.id (cascade delete) |
|
||||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||||
| `joined_at` | DATETIME | When the user joined |
|
||||
|
||||
Unique constraint on `(channel_id, user_id)`.
|
||||
Unique constraint on `(channel_id, session_id)`.
|
||||
|
||||
#### `messages`
|
||||
| Column | Type | Description |
|
||||
@@ -1776,6 +2048,7 @@ Unique constraint on `(channel_id, user_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 |
|
||||
@@ -1786,11 +2059,11 @@ Indexes on `(msg_to, id)` and `(created_at)`.
|
||||
| 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 |
|
||||
| `client_id` | INTEGER | FK → clients.id (cascade delete) |
|
||||
| `message_id`| INTEGER | FK → messages.id (cascade delete) |
|
||||
| `created_at`| DATETIME | When the entry was queued |
|
||||
|
||||
Unique constraint on `(user_id, message_id)`. Index on `(user_id, id)`.
|
||||
Unique constraint on `(client_id, message_id)`. Index on `(client_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
|
||||
@@ -1803,10 +2076,19 @@ 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).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1955,58 +2237,59 @@ A complete client needs only four HTTP calls:
|
||||
### Step-by-Step with curl
|
||||
|
||||
```bash
|
||||
# 1. Create a session
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
# 1a. Create a session (cookie saved automatically with -c)
|
||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser"}' | jq -r .token)
|
||||
-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"}'
|
||||
|
||||
# 2. Join a channel
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"JOIN","to":"#general"}'
|
||||
|
||||
# 3. Send a message
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
|
||||
|
||||
# 4. Poll for messages (one-shot)
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | 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 .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
|
||||
|
||||
# 6. Send a DM
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-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" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-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" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-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" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-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" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"QUIT","body":["leaving"]}'
|
||||
```
|
||||
@@ -2016,27 +2299,25 @@ curl -s -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
|
||||
import requests, json
|
||||
# Python example — using requests.Session for automatic cookie handling
|
||||
import requests, json, time
|
||||
|
||||
BASE = "http://localhost:8080/api/v1"
|
||||
token = None
|
||||
session = requests.Session() # Manages cookies automatically
|
||||
last_id = 0
|
||||
|
||||
# Create session
|
||||
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
|
||||
token = resp.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# Create session (cookie set automatically via Set-Cookie header)
|
||||
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
|
||||
print(f"Session: {resp.json()}")
|
||||
|
||||
# Join channel
|
||||
requests.post(f"{BASE}/messages", headers=headers,
|
||||
session.post(f"{BASE}/messages",
|
||||
json={"command": "JOIN", "to": "#general"})
|
||||
|
||||
# Poll loop
|
||||
while True:
|
||||
try:
|
||||
resp = requests.get(f"{BASE}/messages",
|
||||
headers=headers,
|
||||
resp = session.get(f"{BASE}/messages",
|
||||
params={"after": last_id, "timeout": 15},
|
||||
timeout=20) # HTTP timeout > long-poll timeout
|
||||
data = resp.json()
|
||||
@@ -2053,14 +2334,14 @@ while True:
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript/browser example
|
||||
async function pollLoop(token) {
|
||||
// JavaScript/browser example — cookies sent automatically
|
||||
async function pollLoop() {
|
||||
let lastId = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/v1/messages?after=${lastId}&timeout=15`,
|
||||
{headers: {'Authorization': `Bearer ${token}`}}
|
||||
{credentials: 'same-origin'} // Include cookies
|
||||
);
|
||||
if (resp.status === 401) { /* session expired */ break; }
|
||||
const data = await resp.json();
|
||||
@@ -2092,9 +2373,11 @@ Clients should handle these message commands from the queue:
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **HTTP 401**: Token expired or invalid. Re-create session.
|
||||
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
|
||||
re-login (if a password was set).
|
||||
- **HTTP 404**: Channel or user not found.
|
||||
- **HTTP 409**: Nick already taken (on session creation or NICK change).
|
||||
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||
NICK change).
|
||||
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
||||
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
||||
|
||||
@@ -2111,8 +2394,11 @@ 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 session is gone.
|
||||
Create a new session. If it fails with a network error, retry with backoff.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -2271,8 +2557,10 @@ 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)
|
||||
- [ ] **Multi-client sessions** — add client to existing session
|
||||
(share nick across devices)
|
||||
- [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
|
||||
|
||||
### Future (1.0+)
|
||||
|
||||
@@ -2332,6 +2620,8 @@ 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)
|
||||
@@ -2383,9 +2673,12 @@ neoirc/
|
||||
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, no email verification. Session creation is
|
||||
instant. The cost of entry is a hashcash proof, not bureaucracy.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,7 @@ func main() {
|
||||
server.New,
|
||||
middleware.New,
|
||||
healthcheck.New,
|
||||
stats.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server) {}),
|
||||
).Run()
|
||||
|
||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/getsentry/sentry-go v0.42.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,16 +29,19 @@ 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.
|
||||
// NewClient creates a new API client with a cookie jar
|
||||
// for automatic auth cookie management.
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
||||
jar, _ := cookiejar.New(nil)
|
||||
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
||||
Timeout: httpTimeout,
|
||||
Jar: jar,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -79,8 +83,6 @@ func (client *Client) CreateSession(
|
||||
return nil, fmt.Errorf("decode session: %w", err)
|
||||
}
|
||||
|
||||
client.Token = resp.Token
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -121,6 +123,7 @@ func (client *Client) PollMessages(
|
||||
Timeout: time.Duration(
|
||||
timeout+pollExtraTime,
|
||||
) * time.Second,
|
||||
Jar: client.HTTPClient.Jar,
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
@@ -145,10 +148,6 @@ 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)
|
||||
@@ -304,12 +303,6 @@ 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)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +39,23 @@ func MintHashcash(bits int, resource string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// MintChannelHashcash computes a hashcash stamp bound to
|
||||
// a specific channel and message body. The stamp format
|
||||
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
||||
// bodyhash is the hex-encoded SHA-256 of the message
|
||||
// body bytes. Delegates to the internal/hashcash package.
|
||||
func MintChannelHashcash(
|
||||
bits int,
|
||||
channel string,
|
||||
body []byte,
|
||||
) string {
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
return hashcash.MintChannelStamp(
|
||||
bits, channel, bodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if hash has at least numBits
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(
|
||||
|
||||
@@ -12,7 +12,6 @@ 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.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -16,80 +16,28 @@ var errNoPassword = errors.New(
|
||||
"account has no password set",
|
||||
)
|
||||
|
||||
// RegisterUser creates a session with a hashed password
|
||||
// and returns session ID, client ID, and token.
|
||||
func (database *Database) RegisterUser(
|
||||
// SetPassword sets a bcrypt-hashed password on a session,
|
||||
// enabling multi-client login via POST /api/v1/login.
|
||||
func (database *Database) SetPassword(
|
||||
ctx context.Context,
|
||||
nick, password string,
|
||||
) (int64, int64, string, error) {
|
||||
sessionID int64,
|
||||
password string,
|
||||
) error {
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password), bcryptCost,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"hash password: %w", err,
|
||||
)
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
sessionUUID := uuid.New().String()
|
||||
clientUUID := uuid.New().String()
|
||||
|
||||
token, err := generateToken()
|
||||
_, err = database.conn.ExecContext(ctx,
|
||||
"UPDATE sessions SET password_hash = ? WHERE id = ?",
|
||||
string(hash), sessionID)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
return fmt.Errorf("set password: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"begin tx: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
res, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO sessions
|
||||
(uuid, nick, password_hash,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, string(hash), now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"create session: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
sessionID, _ := res.LastInsertId()
|
||||
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginUser verifies a nick/password and creates a new
|
||||
|
||||
@@ -6,63 +6,65 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestRegisterUser(t *testing.T) {
|
||||
func TestSetPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
database.RegisterUser(ctx, "reguser", "password123")
|
||||
sessionID, _, _, err :=
|
||||
database.CreateSession(ctx, "passuser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sessionID == 0 || clientID == 0 || token == "" {
|
||||
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 == "" {
|
||||
t.Fatal("expected valid ids and token")
|
||||
}
|
||||
|
||||
// Verify session works via token lookup.
|
||||
sid, cid, nick, err :=
|
||||
database.GetSessionByToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sid != sessionID || cid != clientID {
|
||||
t.Fatal("session/client id mismatch")
|
||||
}
|
||||
|
||||
if nick != "reguser" {
|
||||
t.Fatalf("expected reguser, got %s", nick)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||
func TestSetPasswordThenWrongLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "dupnick", "password123")
|
||||
sessionID, _, _, err :=
|
||||
database.CreateSession(ctx, "wrongpw")
|
||||
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")
|
||||
err = database.SetPassword(
|
||||
ctx, sessionID, "correctpass",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = dupSID
|
||||
_ = dupCID
|
||||
_ = dupToken
|
||||
loginSID, loginCID, loginToken, loginErr :=
|
||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||
if loginErr == nil {
|
||||
t.Fatal("expected error for wrong password")
|
||||
}
|
||||
|
||||
_ = loginSID
|
||||
_ = loginCID
|
||||
_ = loginToken
|
||||
}
|
||||
|
||||
func TestLoginUser(t *testing.T) {
|
||||
@@ -71,23 +73,26 @@ func TestLoginUser(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "loginuser", "mypassword")
|
||||
sessionID, _, _, err :=
|
||||
database.CreateSession(ctx, "loginuser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = regSID
|
||||
_ = regCID
|
||||
_ = regToken
|
||||
err = database.SetPassword(
|
||||
ctx, sessionID, "mypassword",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
loginSID, loginCID, token, err :=
|
||||
database.LoginUser(ctx, "loginuser", "mypassword")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sessionID == 0 || clientID == 0 || token == "" {
|
||||
if loginSID == 0 || loginCID == 0 || token == "" {
|
||||
t.Fatal("expected valid ids and token")
|
||||
}
|
||||
|
||||
@@ -103,33 +108,6 @@ 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()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -746,8 +746,8 @@ func scanMessages(
|
||||
code, _ := strconv.Atoi(msg.Command)
|
||||
msg.Code = code
|
||||
|
||||
if name := irc.Name(code); name != "" {
|
||||
msg.Command = name
|
||||
if mt, err := irc.FromInt(code); err == nil {
|
||||
msg.Command = mt.Name()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,6 +1110,121 @@ func (database *Database) GetSessionCreatedAt(
|
||||
return createdAt, nil
|
||||
}
|
||||
|
||||
// SetAway sets the away message for a session.
|
||||
// An empty message clears the away status.
|
||||
func (database *Database) SetAway(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
message string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
"UPDATE sessions SET away_message = ? WHERE id = ?",
|
||||
message, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set away: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAway returns the away message for a session.
|
||||
// Returns an empty string if the user is not away.
|
||||
func (database *Database) GetAway(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (string, error) {
|
||||
var msg string
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
"SELECT away_message FROM sessions WHERE id = ?",
|
||||
sessionID,
|
||||
).Scan(&msg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get away: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SetTopicMeta sets the topic along with who set it and
|
||||
// when.
|
||||
func (database *Database) SetTopicMeta(
|
||||
ctx context.Context,
|
||||
channelName, topic, setBy string,
|
||||
) error {
|
||||
now := time.Now()
|
||||
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET topic = ?, topic_set_by = ?,
|
||||
topic_set_at = ?, updated_at = ?
|
||||
WHERE name = ?`,
|
||||
topic, setBy, now, now, channelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set topic meta: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopicMeta holds topic metadata for a channel.
|
||||
type TopicMeta struct {
|
||||
SetBy string
|
||||
SetAt time.Time
|
||||
}
|
||||
|
||||
// GetTopicMeta returns who set the topic and when.
|
||||
func (database *Database) GetTopicMeta(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (*TopicMeta, error) {
|
||||
var (
|
||||
setBy string
|
||||
setAt sql.NullTime
|
||||
)
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT topic_set_by, topic_set_at
|
||||
FROM channels WHERE id = ?`,
|
||||
channelID,
|
||||
).Scan(&setBy, &setAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get topic meta: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if setBy == "" || !setAt.Valid {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
return &TopicMeta{
|
||||
SetBy: setBy,
|
||||
SetAt: setAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSessionLastSeen returns the last_seen time for a
|
||||
// session.
|
||||
func (database *Database) GetSessionLastSeen(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (time.Time, error) {
|
||||
var lastSeen time.Time
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
"SELECT last_seen FROM sessions WHERE id = ?",
|
||||
sessionID,
|
||||
).Scan(&lastSeen)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"get session last_seen: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return lastSeen, nil
|
||||
}
|
||||
|
||||
// PruneOldQueueEntries deletes client output queue entries
|
||||
// older than cutoff and returns the number of rows removed.
|
||||
func (database *Database) PruneOldQueueEntries(
|
||||
@@ -1151,3 +1266,149 @@ 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(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (int, error) {
|
||||
var bits int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT hashcash_bits FROM channels WHERE id = ?",
|
||||
channelID,
|
||||
).Scan(&bits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"get channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
// SetChannelHashcashBits sets the hashcash difficulty
|
||||
// requirement for a channel. A value of 0 disables the
|
||||
// requirement.
|
||||
func (database *Database) SetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
bits int,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET hashcash_bits = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
bits, time.Now(), channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"set channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSpentHashcash stores a spent hashcash stamp hash
|
||||
// for replay prevention.
|
||||
func (database *Database) RecordSpentHashcash(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO spent_hashcash
|
||||
(stamp_hash, created_at)
|
||||
VALUES (?, ?)`,
|
||||
stampHash, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"record spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsHashcashSpent checks whether a hashcash stamp hash
|
||||
// has already been used.
|
||||
func (database *Database) IsHashcashSpent(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) (bool, error) {
|
||||
var count int
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM spent_hashcash
|
||||
WHERE stamp_hash = ?`,
|
||||
stampHash,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// PruneSpentHashcash deletes spent hashcash tokens older
|
||||
// than the cutoff and returns the number of rows removed.
|
||||
func (database *Database) PruneSpentHashcash(
|
||||
ctx context.Context,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
"DELETE FROM spent_hashcash WHERE created_at < ?",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"prune spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
nick TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
signing_key TEXT NOT NULL DEFAULT '',
|
||||
away_message TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -30,6 +31,9 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||
topic_set_at DATETIME,
|
||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -58,6 +62,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
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);
|
||||
|
||||
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||
CREATE TABLE IF NOT EXISTS spent_hashcash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stamp_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
|
||||
|
||||
-- Per-client message queues for fan-out delivery
|
||||
CREATE TABLE IF NOT EXISTS client_queues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,117 +5,11 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -180,6 +74,8 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
@@ -190,9 +86,66 @@ 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)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -30,10 +31,16 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||
|
||||
// spentHashcashTTL is how long spent hashcash tokens are
|
||||
// retained for replay prevention. Per issue requirements,
|
||||
// this is 1 year.
|
||||
const spentHashcashTTL = 365 * 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
@@ -41,6 +48,8 @@ type Handlers struct {
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -60,6 +69,8 @@ func New(
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
@@ -281,4 +292,20 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune spent hashcash tokens older than 1 year.
|
||||
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
|
||||
|
||||
pruned, err := hdlr.params.Database.
|
||||
PruneSpentHashcash(ctx, hashcashCutoff)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"spent hashcash pruning failed", "error", err,
|
||||
)
|
||||
} else if pruned > 0 {
|
||||
hdlr.log.Info(
|
||||
"pruned spent hashcash tokens",
|
||||
"deleted", pruned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
resp := hdlr.hc.Healthcheck()
|
||||
resp := hdlr.hc.Healthcheck(request.Context())
|
||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
186
internal/hashcash/channel.go
Normal file
186
internal/hashcash/channel.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errBodyHashMismatch = errors.New(
|
||||
"body hash mismatch",
|
||||
)
|
||||
errBodyHashMissing = errors.New(
|
||||
"body hash missing",
|
||||
)
|
||||
)
|
||||
|
||||
// ChannelValidator checks hashcash stamps for
|
||||
// per-channel PRIVMSG validation. It verifies that
|
||||
// stamps are bound to a specific channel and message
|
||||
// body. Replay prevention is handled externally via
|
||||
// the database spent_hashcash table for persistence
|
||||
// across server restarts (1-year TTL).
|
||||
type ChannelValidator struct{}
|
||||
|
||||
// NewChannelValidator creates a ChannelValidator.
|
||||
func NewChannelValidator() *ChannelValidator {
|
||||
return &ChannelValidator{}
|
||||
}
|
||||
|
||||
// BodyHash computes the hex-encoded SHA-256 hash of a
|
||||
// message body for use in hashcash stamp validation.
|
||||
func BodyHash(body []byte) string {
|
||||
hash := sha256.Sum256(body)
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ValidateStamp checks a channel hashcash stamp. It
|
||||
// verifies the stamp format, difficulty, date, channel
|
||||
// binding, body hash binding, and proof-of-work. Replay
|
||||
// detection is NOT performed here — callers must check
|
||||
// the spent_hashcash table separately.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func (cv *ChannelValidator) ValidateStamp(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
}
|
||||
|
||||
version := parts[0]
|
||||
bitsStr := parts[1]
|
||||
dateStr := parts[2]
|
||||
resource := parts[3]
|
||||
stampBodyHash := parts[4]
|
||||
|
||||
headerErr := validateChannelHeader(
|
||||
version, bitsStr, resource,
|
||||
requiredBits, channel,
|
||||
)
|
||||
if headerErr != nil {
|
||||
return headerErr
|
||||
}
|
||||
|
||||
stampTime, parseErr := parseStampDate(dateStr)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
timeErr := validateTime(stampTime)
|
||||
if timeErr != nil {
|
||||
return timeErr
|
||||
}
|
||||
|
||||
bodyErr := validateBodyHash(
|
||||
stampBodyHash, bodyHash,
|
||||
)
|
||||
if bodyErr != nil {
|
||||
return bodyErr
|
||||
}
|
||||
|
||||
return validateProof(stamp, requiredBits)
|
||||
}
|
||||
|
||||
// StampHash returns a deterministic hash of a stamp
|
||||
// string for use as a spent-token key.
|
||||
func StampHash(stamp string) string {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func validateChannelHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
) error {
|
||||
if version != stampVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errBadVersion, version,
|
||||
)
|
||||
}
|
||||
|
||||
claimedBits, err := strconv.Atoi(bitsStr)
|
||||
if err != nil || claimedBits < requiredBits {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d bits",
|
||||
errInsufficientBits, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
if resource != channel {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, channel,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBodyHash(
|
||||
stampBodyHash, expectedBodyHash string,
|
||||
) error {
|
||||
if stampBodyHash == "" {
|
||||
return errBodyHashMissing
|
||||
}
|
||||
|
||||
if stampBodyHash != expectedBodyHash {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errBodyHashMismatch,
|
||||
stampBodyHash, expectedBodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MintChannelStamp computes a channel hashcash stamp
|
||||
// with the given difficulty, channel name, and body hash.
|
||||
// This is intended for clients to generate stamps before
|
||||
// sending PRIVMSG to hashcash-protected channels.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func MintChannelStamp(
|
||||
bits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) string {
|
||||
date := time.Now().UTC().Format(dateFormatShort)
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s:%s:",
|
||||
bits, date, channel, bodyHash,
|
||||
)
|
||||
|
||||
counter := uint64(0)
|
||||
|
||||
for {
|
||||
stamp := prefix + strconv.FormatUint(counter, 16)
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
}
|
||||
244
internal/hashcash/channel_test.go
Normal file
244
internal/hashcash/channel_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package hashcash_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
testChannel = "#general"
|
||||
testBodyText = `["hello world"]`
|
||||
)
|
||||
|
||||
func testBodyHash() string {
|
||||
hash := sha256.Sum256([]byte(testBodyText))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func TestChannelValidateHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("valid channel stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, "#other", bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected channel mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
wrongHash := sha256.Sum256([]byte("different body"))
|
||||
wrongBodyHash := hex.EncodeToString(wrongHash[:])
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, wrongBodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected body hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateInsufficientBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with 2 bits but require 4.
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, 4, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient bits error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateZeroBitsSkips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"garbage", 0, "#ch", "abc",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"not:valid", testBits, testChannel, "abc",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateExpiredStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with a very old date by manually constructing.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, "200101",
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired stamp error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateMissingBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Construct a stamp with empty body hash field.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, todayDate(),
|
||||
)
|
||||
|
||||
// This uses the session-style stamp which has empty
|
||||
// ext field — body hash is missing.
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing body hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`["hello world"]`)
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
if len(bodyHash) != 64 {
|
||||
t.Fatalf(
|
||||
"expected 64-char hex hash, got %d",
|
||||
len(bodyHash),
|
||||
)
|
||||
}
|
||||
|
||||
// Same input should produce same hash.
|
||||
bodyHash2 := hashcash.BodyHash(body)
|
||||
if bodyHash != bodyHash2 {
|
||||
t.Fatal("body hash not deterministic")
|
||||
}
|
||||
|
||||
// Different input should produce different hash.
|
||||
bodyHash3 := hashcash.BodyHash([]byte("different"))
|
||||
if bodyHash == bodyHash3 {
|
||||
t.Fatal("different inputs produced same hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash1 := hashcash.StampHash("stamp1")
|
||||
hash2 := hashcash.StampHash("stamp2")
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("different stamps produced same hash")
|
||||
}
|
||||
|
||||
// Same input should be deterministic.
|
||||
hash1b := hashcash.StampHash("stamp1")
|
||||
if hash1 != hash1b {
|
||||
t.Fatal("stamp hash not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintChannelStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bodyHash := testBodyHash()
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
if stamp == "" {
|
||||
t.Fatal("expected non-empty stamp")
|
||||
}
|
||||
|
||||
// Validate the minted stamp.
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("minted stamp failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,7 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *db.Database
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
// Healthcheck tracks server uptime and provides health status.
|
||||
@@ -64,11 +66,22 @@ 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() *Response {
|
||||
return &Response{
|
||||
func (hcheck *Healthcheck) Healthcheck(
|
||||
ctx context.Context,
|
||||
) *Response {
|
||||
resp := &Response{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||
@@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
// Package irc provides constants and utilities for the
|
||||
// IRC protocol, including numeric reply codes from
|
||||
// RFC 1459 and RFC 2812, and standard command names.
|
||||
package irc
|
||||
|
||||
// Connection registration replies (001-005).
|
||||
const (
|
||||
RplWelcome = 1
|
||||
RplYourHost = 2
|
||||
RplCreated = 3
|
||||
RplMyInfo = 4
|
||||
RplIsupport = 5
|
||||
)
|
||||
|
||||
// Command responses (200-399).
|
||||
const (
|
||||
RplUmodeIs = 221
|
||||
RplLuserClient = 251
|
||||
RplLuserOp = 252
|
||||
RplLuserUnknown = 253
|
||||
RplLuserChannels = 254
|
||||
RplLuserMe = 255
|
||||
RplAway = 301
|
||||
RplUserHost = 302
|
||||
RplIson = 303
|
||||
RplUnaway = 305
|
||||
RplNowAway = 306
|
||||
RplWhoisUser = 311
|
||||
RplWhoisServer = 312
|
||||
RplWhoisOperator = 313
|
||||
RplEndOfWho = 315
|
||||
RplWhoisIdle = 317
|
||||
RplEndOfWhois = 318
|
||||
RplWhoisChannels = 319
|
||||
RplList = 322
|
||||
RplListEnd = 323
|
||||
RplChannelModeIs = 324
|
||||
RplCreationTime = 329
|
||||
RplNoTopic = 331
|
||||
RplTopic = 332
|
||||
RplTopicWhoTime = 333
|
||||
RplInviting = 341
|
||||
RplWhoReply = 352
|
||||
RplNamReply = 353
|
||||
RplEndOfNames = 366
|
||||
RplBanList = 367
|
||||
RplEndOfBanList = 368
|
||||
RplMotd = 372
|
||||
RplMotdStart = 375
|
||||
RplEndOfMotd = 376
|
||||
)
|
||||
|
||||
// Error replies (400-599).
|
||||
const (
|
||||
ErrNoSuchNick = 401
|
||||
ErrNoSuchServer = 402
|
||||
ErrNoSuchChannel = 403
|
||||
ErrCannotSendToChan = 404
|
||||
ErrTooManyChannels = 405
|
||||
ErrNoRecipient = 411
|
||||
ErrNoTextToSend = 412
|
||||
ErrUnknownCommand = 421
|
||||
ErrNoNicknameGiven = 431
|
||||
ErrErroneusNickname = 432
|
||||
ErrNicknameInUse = 433
|
||||
ErrUserNotInChannel = 441
|
||||
ErrNotOnChannel = 442
|
||||
ErrNotRegistered = 451
|
||||
ErrNeedMoreParams = 461
|
||||
ErrAlreadyRegistered = 462
|
||||
ErrChannelIsFull = 471
|
||||
ErrInviteOnlyChan = 473
|
||||
ErrBannedFromChan = 474
|
||||
ErrBadChannelKey = 475
|
||||
ErrChanOpPrivsNeeded = 482
|
||||
)
|
||||
|
||||
// names maps numeric codes to their standard IRC names.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var names = map[int]string{
|
||||
RplWelcome: "RPL_WELCOME",
|
||||
RplYourHost: "RPL_YOURHOST",
|
||||
RplCreated: "RPL_CREATED",
|
||||
RplMyInfo: "RPL_MYINFO",
|
||||
RplIsupport: "RPL_ISUPPORT",
|
||||
RplUmodeIs: "RPL_UMODEIS",
|
||||
RplLuserClient: "RPL_LUSERCLIENT",
|
||||
RplLuserOp: "RPL_LUSEROP",
|
||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||
RplLuserMe: "RPL_LUSERME",
|
||||
RplAway: "RPL_AWAY",
|
||||
RplUserHost: "RPL_USERHOST",
|
||||
RplIson: "RPL_ISON",
|
||||
RplUnaway: "RPL_UNAWAY",
|
||||
RplNowAway: "RPL_NOWAWAY",
|
||||
RplWhoisUser: "RPL_WHOISUSER",
|
||||
RplWhoisServer: "RPL_WHOISSERVER",
|
||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||
RplEndOfWho: "RPL_ENDOFWHO",
|
||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||
RplList: "RPL_LIST",
|
||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||
RplCreationTime: "RPL_CREATIONTIME",
|
||||
RplNoTopic: "RPL_NOTOPIC",
|
||||
RplTopic: "RPL_TOPIC",
|
||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||
RplInviting: "RPL_INVITING",
|
||||
RplWhoReply: "RPL_WHOREPLY",
|
||||
RplNamReply: "RPL_NAMREPLY",
|
||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||
RplBanList: "RPL_BANLIST",
|
||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||
RplMotd: "RPL_MOTD",
|
||||
RplMotdStart: "RPL_MOTDSTART",
|
||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||
|
||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||
}
|
||||
|
||||
// Name returns the standard IRC name for a numeric code
|
||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||
// empty string if the code is unknown.
|
||||
func Name(code int) string {
|
||||
return names[code]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
@@ -126,18 +126,23 @@ 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
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowOriginFunc: func(
|
||||
_ *http.Request, _ string,
|
||||
) bool {
|
||||
return true
|
||||
},
|
||||
AllowedMethods: []string{
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||
},
|
||||
AllowedHeaders: []string{
|
||||
"Accept", "Authorization",
|
||||
"Content-Type", "X-CSRF-Token",
|
||||
"Accept", "Content-Type", "X-CSRF-Token",
|
||||
},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
AllowCredentials: true,
|
||||
MaxAge: corsMaxAge,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/web"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -75,10 +75,6 @@ func (srv *Server) setupAPIv1(router chi.Router) {
|
||||
"/session",
|
||||
srv.handlers.HandleCreateSession(),
|
||||
)
|
||||
router.Post(
|
||||
"/register",
|
||||
srv.handlers.HandleRegister(),
|
||||
)
|
||||
router.Post(
|
||||
"/login",
|
||||
srv.handlers.HandleLogin(),
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||
)
|
||||
|
||||
52
internal/stats/stats.go
Normal file
52
internal/stats/stats.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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()
|
||||
}
|
||||
117
internal/stats/stats_test.go
Normal file
117
internal/stats/stats_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package irc
|
||||
|
||||
// IRC command names (RFC 1459 / RFC 2812).
|
||||
const (
|
||||
CmdAway = "AWAY"
|
||||
CmdJoin = "JOIN"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
@@ -10,6 +11,7 @@ const (
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdPass = "PASS"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
391
pkg/irc/numerics.go
Normal file
391
pkg/irc/numerics.go
Normal file
@@ -0,0 +1,391 @@
|
||||
// Package irc provides constants and utilities for the
|
||||
// IRC protocol, including numeric reply codes from
|
||||
// RFC 1459 and RFC 2812, and standard command names.
|
||||
package irc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// IRCMessageType represents an IRC numeric reply or error code.
|
||||
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
||||
|
||||
// Name returns the standard IRC name for this numeric code
|
||||
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
||||
// Returns an empty string if the code is unknown.
|
||||
func (t IRCMessageType) Name() string {
|
||||
return names[t]
|
||||
}
|
||||
|
||||
// String returns the name and numeric code in angle brackets
|
||||
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
||||
// If the code is unknown, returns "UNKNOWN <NNN>".
|
||||
func (t IRCMessageType) String() string {
|
||||
n := names[t]
|
||||
if n == "" {
|
||||
n = "UNKNOWN"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s <%03d>", n, int(t))
|
||||
}
|
||||
|
||||
// Code returns the three-digit zero-padded string representation
|
||||
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
||||
func (t IRCMessageType) Code() string {
|
||||
return fmt.Sprintf("%03d", int(t))
|
||||
}
|
||||
|
||||
// Int returns the bare integer value of the numeric code.
|
||||
func (t IRCMessageType) Int() int {
|
||||
return int(t)
|
||||
}
|
||||
|
||||
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
||||
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
||||
|
||||
// FromInt converts an integer to an IRCMessageType, returning an error
|
||||
// if the numeric code is not a known IRC reply or error code.
|
||||
func FromInt(n int) (IRCMessageType, error) {
|
||||
t := IRCMessageType(n)
|
||||
if _, ok := names[t]; !ok {
|
||||
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Connection registration replies (001-005).
|
||||
const (
|
||||
RplWelcome IRCMessageType = 1
|
||||
RplYourHost IRCMessageType = 2
|
||||
RplCreated IRCMessageType = 3
|
||||
RplMyInfo IRCMessageType = 4
|
||||
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
||||
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
||||
)
|
||||
|
||||
// Command responses (200-399).
|
||||
const (
|
||||
// RFC 2812 trace/stats/links replies (200-219).
|
||||
RplTraceLink IRCMessageType = 200
|
||||
RplTraceConnecting IRCMessageType = 201
|
||||
RplTraceHandshake IRCMessageType = 202
|
||||
RplTraceUnknown IRCMessageType = 203
|
||||
RplTraceOperator IRCMessageType = 204
|
||||
RplTraceUser IRCMessageType = 205
|
||||
RplTraceServer IRCMessageType = 206
|
||||
RplTraceService IRCMessageType = 207
|
||||
RplTraceNewType IRCMessageType = 208
|
||||
RplTraceClass IRCMessageType = 209
|
||||
RplStatsLinkInfo IRCMessageType = 211
|
||||
RplStatsCommands IRCMessageType = 212
|
||||
RplStatsCLine IRCMessageType = 213
|
||||
RplStatsNLine IRCMessageType = 214
|
||||
RplStatsILine IRCMessageType = 215
|
||||
RplStatsKLine IRCMessageType = 216
|
||||
RplStatsQLine IRCMessageType = 217
|
||||
RplStatsYLine IRCMessageType = 218
|
||||
RplEndOfStats IRCMessageType = 219
|
||||
|
||||
RplUmodeIs IRCMessageType = 221
|
||||
RplServList IRCMessageType = 234
|
||||
RplServListEnd IRCMessageType = 235
|
||||
RplStatsLLine IRCMessageType = 241
|
||||
RplStatsUptime IRCMessageType = 242
|
||||
RplStatsOLine IRCMessageType = 243
|
||||
RplStatsHLine IRCMessageType = 244
|
||||
RplLuserClient IRCMessageType = 251
|
||||
RplLuserOp IRCMessageType = 252
|
||||
RplLuserUnknown IRCMessageType = 253
|
||||
|
||||
RplLuserChannels IRCMessageType = 254
|
||||
RplLuserMe IRCMessageType = 255
|
||||
RplAdminMe IRCMessageType = 256
|
||||
RplAdminLoc1 IRCMessageType = 257
|
||||
RplAdminLoc2 IRCMessageType = 258
|
||||
RplAdminEmail IRCMessageType = 259
|
||||
RplTraceLog IRCMessageType = 261
|
||||
RplTraceEnd IRCMessageType = 262
|
||||
RplTryAgain IRCMessageType = 263
|
||||
|
||||
RplAway IRCMessageType = 301
|
||||
RplUserHost IRCMessageType = 302
|
||||
RplIson IRCMessageType = 303
|
||||
RplUnaway IRCMessageType = 305
|
||||
RplNowAway IRCMessageType = 306
|
||||
RplWhoisUser IRCMessageType = 311
|
||||
RplWhoisServer IRCMessageType = 312
|
||||
RplWhoisOperator IRCMessageType = 313
|
||||
RplWhoWasUser IRCMessageType = 314
|
||||
RplEndOfWho IRCMessageType = 315
|
||||
RplWhoisIdle IRCMessageType = 317
|
||||
RplEndOfWhois IRCMessageType = 318
|
||||
RplWhoisChannels IRCMessageType = 319
|
||||
RplListStart IRCMessageType = 321
|
||||
RplList IRCMessageType = 322
|
||||
RplListEnd IRCMessageType = 323
|
||||
RplChannelModeIs IRCMessageType = 324
|
||||
|
||||
RplUniqOpIs IRCMessageType = 325
|
||||
RplCreationTime IRCMessageType = 329
|
||||
RplNoTopic IRCMessageType = 331
|
||||
RplTopic IRCMessageType = 332
|
||||
RplTopicWhoTime IRCMessageType = 333
|
||||
RplInviting IRCMessageType = 341
|
||||
RplSummoning IRCMessageType = 342
|
||||
RplInviteList IRCMessageType = 346
|
||||
RplEndOfInviteList IRCMessageType = 347
|
||||
RplExceptList IRCMessageType = 348
|
||||
RplEndOfExceptList IRCMessageType = 349
|
||||
RplVersion IRCMessageType = 351
|
||||
RplWhoReply IRCMessageType = 352
|
||||
RplNamReply IRCMessageType = 353
|
||||
RplLinks IRCMessageType = 364
|
||||
RplEndOfLinks IRCMessageType = 365
|
||||
RplEndOfNames IRCMessageType = 366
|
||||
RplBanList IRCMessageType = 367
|
||||
RplEndOfBanList IRCMessageType = 368
|
||||
RplEndOfWhowas IRCMessageType = 369
|
||||
RplInfo IRCMessageType = 371
|
||||
RplMotd IRCMessageType = 372
|
||||
RplEndOfInfo IRCMessageType = 374
|
||||
RplMotdStart IRCMessageType = 375
|
||||
RplEndOfMotd IRCMessageType = 376
|
||||
RplYoureOper IRCMessageType = 381
|
||||
RplRehashing IRCMessageType = 382
|
||||
RplYoureService IRCMessageType = 383
|
||||
RplTime IRCMessageType = 391
|
||||
RplUsersStart IRCMessageType = 392
|
||||
RplUsers IRCMessageType = 393
|
||||
RplEndOfUsers IRCMessageType = 394
|
||||
RplNoUsers IRCMessageType = 395
|
||||
)
|
||||
|
||||
// Error replies (400-599).
|
||||
const (
|
||||
ErrNoSuchNick IRCMessageType = 401
|
||||
ErrNoSuchServer IRCMessageType = 402
|
||||
ErrNoSuchChannel IRCMessageType = 403
|
||||
ErrCannotSendToChan IRCMessageType = 404
|
||||
ErrTooManyChannels IRCMessageType = 405
|
||||
ErrWasNoSuchNick IRCMessageType = 406
|
||||
ErrTooManyTargets IRCMessageType = 407
|
||||
ErrNoSuchService IRCMessageType = 408
|
||||
ErrNoOrigin IRCMessageType = 409
|
||||
ErrNoRecipient IRCMessageType = 411
|
||||
ErrNoTextToSend IRCMessageType = 412
|
||||
ErrNoTopLevel IRCMessageType = 413
|
||||
ErrWildTopLevel IRCMessageType = 414
|
||||
ErrBadMask IRCMessageType = 415
|
||||
ErrUnknownCommand IRCMessageType = 421
|
||||
ErrNoMotd IRCMessageType = 422
|
||||
ErrNoAdminInfo IRCMessageType = 423
|
||||
ErrFileError IRCMessageType = 424
|
||||
ErrNoNicknameGiven IRCMessageType = 431
|
||||
ErrErroneusNickname IRCMessageType = 432
|
||||
ErrNicknameInUse IRCMessageType = 433
|
||||
ErrNickCollision IRCMessageType = 436
|
||||
ErrUnavailResource IRCMessageType = 437
|
||||
|
||||
ErrUserNotInChannel IRCMessageType = 441
|
||||
ErrNotOnChannel IRCMessageType = 442
|
||||
ErrUserOnChannel IRCMessageType = 443
|
||||
ErrNoLogin IRCMessageType = 444
|
||||
ErrSummonDisabled IRCMessageType = 445
|
||||
ErrUsersDisabled IRCMessageType = 446
|
||||
ErrNotRegistered IRCMessageType = 451
|
||||
ErrNeedMoreParams IRCMessageType = 461
|
||||
ErrAlreadyRegistered IRCMessageType = 462
|
||||
ErrNoPermForHost IRCMessageType = 463
|
||||
ErrPasswdMismatch IRCMessageType = 464
|
||||
ErrYoureBannedCreep IRCMessageType = 465
|
||||
ErrYouWillBeBanned IRCMessageType = 466
|
||||
ErrKeySet IRCMessageType = 467
|
||||
ErrChannelIsFull IRCMessageType = 471
|
||||
ErrUnknownMode IRCMessageType = 472
|
||||
ErrInviteOnlyChan IRCMessageType = 473
|
||||
ErrBannedFromChan IRCMessageType = 474
|
||||
ErrBadChannelKey IRCMessageType = 475
|
||||
ErrBadChanMask IRCMessageType = 476
|
||||
ErrNoChanModes IRCMessageType = 477
|
||||
ErrBanListFull IRCMessageType = 478
|
||||
ErrNoPrivileges IRCMessageType = 481
|
||||
ErrChanOpPrivsNeeded IRCMessageType = 482
|
||||
ErrCantKillServer IRCMessageType = 483
|
||||
ErrRestricted IRCMessageType = 484
|
||||
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
||||
ErrNoOperHost IRCMessageType = 491
|
||||
|
||||
ErrUmodeUnknownFlag IRCMessageType = 501
|
||||
ErrUsersDoNotMatch IRCMessageType = 502
|
||||
)
|
||||
|
||||
// names maps numeric codes to their standard IRC names.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var names = map[IRCMessageType]string{
|
||||
RplWelcome: "RPL_WELCOME",
|
||||
RplYourHost: "RPL_YOURHOST",
|
||||
RplCreated: "RPL_CREATED",
|
||||
RplMyInfo: "RPL_MYINFO",
|
||||
RplBounce: "RPL_BOUNCE",
|
||||
|
||||
RplTraceLink: "RPL_TRACELINK",
|
||||
RplTraceConnecting: "RPL_TRACECONNECTING",
|
||||
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
||||
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
||||
RplTraceOperator: "RPL_TRACEOPERATOR",
|
||||
RplTraceUser: "RPL_TRACEUSER",
|
||||
RplTraceServer: "RPL_TRACESERVER",
|
||||
RplTraceService: "RPL_TRACESERVICE",
|
||||
RplTraceNewType: "RPL_TRACENEWTYPE",
|
||||
RplTraceClass: "RPL_TRACECLASS",
|
||||
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
||||
RplStatsCommands: "RPL_STATSCOMMANDS",
|
||||
RplStatsCLine: "RPL_STATSCLINE",
|
||||
RplStatsNLine: "RPL_STATSNLINE",
|
||||
RplStatsILine: "RPL_STATSILINE",
|
||||
RplStatsKLine: "RPL_STATSKLINE",
|
||||
RplStatsQLine: "RPL_STATSQLINE",
|
||||
RplStatsYLine: "RPL_STATSYLINE",
|
||||
RplEndOfStats: "RPL_ENDOFSTATS",
|
||||
|
||||
RplUmodeIs: "RPL_UMODEIS",
|
||||
RplServList: "RPL_SERVLIST",
|
||||
RplServListEnd: "RPL_SERVLISTEND",
|
||||
RplStatsLLine: "RPL_STATSLLINE",
|
||||
RplStatsUptime: "RPL_STATSUPTIME",
|
||||
RplStatsOLine: "RPL_STATSOLINE",
|
||||
RplStatsHLine: "RPL_STATSHLINE",
|
||||
RplLuserClient: "RPL_LUSERCLIENT",
|
||||
RplLuserOp: "RPL_LUSEROP",
|
||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||
|
||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||
RplLuserMe: "RPL_LUSERME",
|
||||
RplAdminMe: "RPL_ADMINME",
|
||||
RplAdminLoc1: "RPL_ADMINLOC1",
|
||||
RplAdminLoc2: "RPL_ADMINLOC2",
|
||||
RplAdminEmail: "RPL_ADMINEMAIL",
|
||||
RplTraceLog: "RPL_TRACELOG",
|
||||
RplTraceEnd: "RPL_TRACEEND",
|
||||
RplTryAgain: "RPL_TRYAGAIN",
|
||||
|
||||
RplAway: "RPL_AWAY",
|
||||
RplUserHost: "RPL_USERHOST",
|
||||
RplIson: "RPL_ISON",
|
||||
RplUnaway: "RPL_UNAWAY",
|
||||
RplNowAway: "RPL_NOWAWAY",
|
||||
RplWhoisUser: "RPL_WHOISUSER",
|
||||
RplWhoisServer: "RPL_WHOISSERVER",
|
||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||
RplWhoWasUser: "RPL_WHOWASUSER",
|
||||
RplEndOfWho: "RPL_ENDOFWHO",
|
||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||
RplListStart: "RPL_LISTSTART",
|
||||
RplList: "RPL_LIST",
|
||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||
|
||||
RplUniqOpIs: "RPL_UNIQOPIS",
|
||||
RplCreationTime: "RPL_CREATIONTIME",
|
||||
RplNoTopic: "RPL_NOTOPIC",
|
||||
RplTopic: "RPL_TOPIC",
|
||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||
RplInviting: "RPL_INVITING",
|
||||
RplSummoning: "RPL_SUMMONING",
|
||||
RplInviteList: "RPL_INVITELIST",
|
||||
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
||||
RplExceptList: "RPL_EXCEPTLIST",
|
||||
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
||||
RplVersion: "RPL_VERSION",
|
||||
RplWhoReply: "RPL_WHOREPLY",
|
||||
RplNamReply: "RPL_NAMREPLY",
|
||||
RplLinks: "RPL_LINKS",
|
||||
RplEndOfLinks: "RPL_ENDOFLINKS",
|
||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||
RplBanList: "RPL_BANLIST",
|
||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
||||
RplInfo: "RPL_INFO",
|
||||
RplMotd: "RPL_MOTD",
|
||||
RplEndOfInfo: "RPL_ENDOFINFO",
|
||||
RplMotdStart: "RPL_MOTDSTART",
|
||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||
RplYoureOper: "RPL_YOUREOPER",
|
||||
RplRehashing: "RPL_REHASHING",
|
||||
RplYoureService: "RPL_YOURESERVICE",
|
||||
RplTime: "RPL_TIME",
|
||||
RplUsersStart: "RPL_USERSSTART",
|
||||
RplUsers: "RPL_USERS",
|
||||
RplEndOfUsers: "RPL_ENDOFUSERS",
|
||||
RplNoUsers: "RPL_NOUSERS",
|
||||
|
||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
||||
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
||||
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
||||
ErrNoOrigin: "ERR_NOORIGIN",
|
||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
||||
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
||||
ErrBadMask: "ERR_BADMASK",
|
||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||
ErrNoMotd: "ERR_NOMOTD",
|
||||
ErrNoAdminInfo: "ERR_NOADMININFO",
|
||||
ErrFileError: "ERR_FILEERROR",
|
||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||
ErrNickCollision: "ERR_NICKCOLLISION",
|
||||
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
||||
|
||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
||||
ErrNoLogin: "ERR_NOLOGIN",
|
||||
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
||||
ErrUsersDisabled: "ERR_USERSDISABLED",
|
||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
||||
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
||||
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
||||
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
||||
ErrKeySet: "ERR_KEYSET",
|
||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||
ErrBadChanMask: "ERR_BADCHANMASK",
|
||||
ErrNoChanModes: "ERR_NOCHANMODES",
|
||||
ErrBanListFull: "ERR_BANLISTFULL",
|
||||
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
||||
ErrRestricted: "ERR_RESTRICTED",
|
||||
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
||||
ErrNoOperHost: "ERR_NOOPERHOST",
|
||||
|
||||
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
||||
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
||||
}
|
||||
|
||||
// Name returns the standard IRC name for a numeric code
|
||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||
// empty string if the code is unknown.
|
||||
//
|
||||
// Deprecated: Use IRCMessageType.Name() instead.
|
||||
func Name(code IRCMessageType) string {
|
||||
return names[code]
|
||||
}
|
||||
163
pkg/irc/numerics_test.go
Normal file
163
pkg/irc/numerics_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package irc_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
numeric irc.IRCMessageType
|
||||
want string
|
||||
}{
|
||||
{irc.RplWelcome, "RPL_WELCOME"},
|
||||
{irc.RplBounce, "RPL_BOUNCE"},
|
||||
{irc.RplLuserOp, "RPL_LUSEROP"},
|
||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
||||
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := tc.numeric.Name(); got != tc.want {
|
||||
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
numeric irc.IRCMessageType
|
||||
want string
|
||||
}{
|
||||
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
||||
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
||||
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := tc.numeric.String(); got != tc.want {
|
||||
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
numeric irc.IRCMessageType
|
||||
want string
|
||||
}{
|
||||
{irc.RplWelcome, "001"},
|
||||
{irc.RplBounce, "005"},
|
||||
{irc.RplLuserOp, "252"},
|
||||
{irc.ErrCannotSendToChan, "404"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := tc.numeric.Code(); got != tc.want {
|
||||
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
numeric irc.IRCMessageType
|
||||
want int
|
||||
}{
|
||||
{irc.RplWelcome, 1},
|
||||
{irc.RplBounce, 5},
|
||||
{irc.RplLuserOp, 252},
|
||||
{irc.ErrCannotSendToChan, 404},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := tc.numeric.Int(); got != tc.want {
|
||||
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromInt_Known(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
code int
|
||||
want irc.IRCMessageType
|
||||
}{
|
||||
{1, irc.RplWelcome},
|
||||
{5, irc.RplBounce},
|
||||
{252, irc.RplLuserOp},
|
||||
{404, irc.ErrCannotSendToChan},
|
||||
{433, irc.ErrNicknameInUse},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got, err := irc.FromInt(test.code)
|
||||
if err != nil {
|
||||
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromInt_Unknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unknowns := []int{0, 999, 600, -1}
|
||||
for _, code := range unknowns {
|
||||
_, err := irc.FromInt(code)
|
||||
if err == nil {
|
||||
t.Errorf("FromInt(%d) expected error, got nil", code)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
||||
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownNumeric_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unknown := irc.IRCMessageType(999)
|
||||
if got := unknown.Name(); got != "" {
|
||||
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownNumeric_String(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unknown := irc.IRCMessageType(999)
|
||||
want := "UNKNOWN <999>"
|
||||
|
||||
if got := unknown.String(); got != want {
|
||||
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeprecatedNameFunc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
||||
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user