Compare commits
1 Commits
feature/ch
...
feat/chi-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73cae71171 |
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||
|
||||
BINARY := neoircd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -7,21 +7,10 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
|
||||
all: check build
|
||||
|
||||
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
||||
# web/embed.go resolves without a full Node.js build. The real SPA is
|
||||
# built by the web-builder Docker stage; these placeholders let
|
||||
# "make test" and "make build" work outside Docker.
|
||||
ensure-web-dist:
|
||||
@if [ ! -d web/dist ]; then \
|
||||
mkdir -p web/dist && \
|
||||
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
|
||||
echo "==> Created placeholder web/dist/ for go:embed"; \
|
||||
fi
|
||||
|
||||
build: ensure-web-dist
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||
|
||||
lint: ensure-web-dist
|
||||
lint:
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
fmt:
|
||||
@@ -31,7 +20,7 @@ fmt:
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||
|
||||
test: ensure-web-dist
|
||||
test:
|
||||
go test -timeout 30s -v -race -cover ./...
|
||||
|
||||
# check runs all validation without making changes
|
||||
|
||||
582
README.md
582
README.md
@@ -113,9 +113,8 @@ mechanisms or stuffing data into CTCP.
|
||||
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
||||
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
|
||||
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
||||
are unique per server. Identity starts with a key — a nick is a display name.
|
||||
Accounts are optional: you can create an anonymous session instantly, or
|
||||
register with a password for multi-client access to a single session.
|
||||
are unique per server. There are no accounts — identity is a key, a nick is a
|
||||
display name.
|
||||
|
||||
### On the resemblance to JSON-RPC
|
||||
|
||||
@@ -149,45 +148,16 @@ 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 — Dual Authentication Model
|
||||
### Identity & Sessions — No Accounts
|
||||
|
||||
The server supports two authentication paths: **anonymous sessions** for
|
||||
instant access, and **optional account registration** for multi-client access.
|
||||
|
||||
#### Anonymous Sessions (No Account Required)
|
||||
|
||||
The simplest entry point. No registration, no passwords.
|
||||
There are no accounts, no registration, no passwords. Identity is a signing
|
||||
key; a nick is just a display name. The two are decoupled.
|
||||
|
||||
- **Session creation**: client sends `POST /api/v1/session` with a desired
|
||||
nick → server 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>`.
|
||||
- Anonymous sessions are ephemeral — when the session expires or the user
|
||||
QUITs, the nick is released and there is no way to reclaim it.
|
||||
|
||||
#### Registered Accounts (Optional)
|
||||
|
||||
For users who want multi-client access (multiple devices sharing one session):
|
||||
|
||||
- **Registration**: client sends `POST /api/v1/register` with a nick and
|
||||
password (minimum 8 characters) → server creates a session with the
|
||||
password hashed via bcrypt, and returns the user ID, nick, and auth token.
|
||||
- **Login**: client sends `POST /api/v1/login` with nick and password →
|
||||
server verifies the password against the stored bcrypt hash and creates a
|
||||
new client token for the existing session. This enables multi-client
|
||||
access: logging in from a new device adds a client to the existing session
|
||||
rather than creating a new one, so channel memberships and message queues
|
||||
are shared. Note: 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 registration is lost.
|
||||
- Registered accounts cannot be logged into via `POST /api/v1/session` —
|
||||
that endpoint is for anonymous sessions only.
|
||||
- Anonymous sessions (created via `/session`) cannot be logged into via
|
||||
`/login` because they have no password set.
|
||||
|
||||
#### Common Properties (Both Paths)
|
||||
|
||||
- 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.
|
||||
@@ -195,17 +165,11 @@ For users who want multi-client access (multiple devices sharing one session):
|
||||
in the token, no client-side decode. The server is the sole authority on
|
||||
token validity.
|
||||
|
||||
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
|
||||
Anonymous sessions preserve that simplicity — instant access, zero friction.
|
||||
But some users want to access the same session from multiple devices without
|
||||
a bouncer. Optional registration with password enables multi-client login
|
||||
without adding friction for casual users: if you don't want an account,
|
||||
don't create one. Note: in the current implementation, both anonymous and
|
||||
registered sessions are deleted when the last client disconnects (QUIT or
|
||||
logout); registration does not make a session survive all-client
|
||||
removal. Identity verification at the message layer via cryptographic
|
||||
signatures (see [Security Model](#security-model)) remains independent
|
||||
of account registration.
|
||||
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
|
||||
registration, email verification, or OAuth would solve a problem nobody asked
|
||||
about and add complexity that drives away casual users. Identity verification
|
||||
is handled at the message layer via cryptographic signatures (see
|
||||
[Security Model](#security-model)), not at the session layer.
|
||||
|
||||
### Nick Semantics
|
||||
|
||||
@@ -243,12 +207,12 @@ User Session
|
||||
└── Client C (token_c, queue_c)
|
||||
```
|
||||
|
||||
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
|
||||
client to an existing registered session, enabling true multi-client support
|
||||
(multiple tokens sharing one nick/session with independent message queues).
|
||||
Anonymous sessions created via `POST /api/v1/session` always create a new
|
||||
user with a new nick. A future endpoint to "add a client to an existing
|
||||
anonymous session" is planned but not yet implemented.
|
||||
**Current MVP note:** The current implementation creates a new user (with new
|
||||
nick) per `POST /api/v1/session` call. True multi-client (multiple tokens
|
||||
sharing one nick/session) is supported by the schema (`client_queues` is keyed
|
||||
by user_id, and multiple tokens can point to the same user) but the session
|
||||
creation endpoint does not yet support "add a client to an existing session."
|
||||
This will be added post-MVP.
|
||||
|
||||
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
||||
phone and laptop connected simultaneously without a bouncer. Server-side
|
||||
@@ -285,8 +249,8 @@ Key properties:
|
||||
- **Ordered**: Queue entries have monotonically increasing IDs. Messages are
|
||||
always delivered in order within a client's queue.
|
||||
- **No delivery/read receipts** for channel messages. DM receipts are planned.
|
||||
- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`.
|
||||
Default is 30 days. Entries older than this are pruned.
|
||||
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48
|
||||
hours. Entries older than this are pruned.
|
||||
|
||||
### Long-Polling
|
||||
|
||||
@@ -363,8 +327,8 @@ 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 SHA-256 hash
|
||||
- Client presents the raw token; server hashes and looks it up
|
||||
- Server generates 32 random bytes → hex-encodes → stores hash
|
||||
- Client presents the token; server looks it up
|
||||
- Revocation is a database delete
|
||||
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
||||
- Token format can change without breaking clients
|
||||
@@ -391,8 +355,6 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
#### Anonymous Session
|
||||
|
||||
```
|
||||
┌─ Client ──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
@@ -423,30 +385,6 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Registered Account
|
||||
|
||||
```
|
||||
┌─ Client ──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1. POST /api/v1/register │
|
||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||
│ (Session created with bcrypt-hashed password) │
|
||||
│ │
|
||||
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
|
||||
│ │
|
||||
│ (From another device, while session is still active) │
|
||||
│ │
|
||||
│ 2. POST /api/v1/login │
|
||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
|
||||
│ (New client added to existing session — channels │
|
||||
│ and message queues are preserved. If all clients │
|
||||
│ have logged out, session no longer exists.) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Queue Architecture
|
||||
|
||||
```
|
||||
@@ -523,7 +461,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`), 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. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
|
||||
|
||||
**Important invariants:**
|
||||
|
||||
@@ -1013,13 +951,12 @@ 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):**
|
||||
|
||||
@@ -1030,56 +967,6 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1100,20 +987,14 @@ the format:
|
||||
|
||||
Create a new user session. This is the entry point for all clients.
|
||||
|
||||
If the server requires hashcash proof-of-work (see
|
||||
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
||||
valid stamp in the `pow_token` field of the JSON request body. The required
|
||||
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|
||||
**Request Body:**
|
||||
**Request:**
|
||||
```json
|
||||
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||
{"nick": "alice"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|-------------|-------------|
|
||||
|--------|--------|----------|-------------|
|
||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
@@ -1135,114 +1016,13 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled |
|
||||
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||
| 409 | `nick already taken` | Another active session holds this nick |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
```
|
||||
|
||||
### POST /api/v1/register — Register Account
|
||||
|
||||
Create a new user session with a password. The password is hashed
|
||||
with bcrypt and stored server-side. The password enables login from
|
||||
additional clients via `POST /api/v1/login` while the session
|
||||
remains active.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{"nick": "alice", "password": "mypassword"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|----------|-------------|
|
||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||
| `password` | string | Yes | Minimum 8 characters |
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"nick": "alice",
|
||||
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|---------|-------------|
|
||||
| `id` | integer | Server-assigned user ID |
|
||||
| `nick` | string | Confirmed nick |
|
||||
| `token` | string | 64-character hex auth token |
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `invalid nick format` | Nick doesn't match allowed format |
|
||||
| 400 | `password must be at least 8 characters` | Password too short |
|
||||
| 409 | `nick already taken` | Another active session holds this nick |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
```
|
||||
|
||||
### POST /api/v1/login — Login to Account
|
||||
|
||||
Authenticate with a previously registered nick and password. Creates a new
|
||||
client token for the existing session, preserving channel memberships and
|
||||
message queues. This is how multi-client access works for registered accounts:
|
||||
each login adds a new client to the session.
|
||||
|
||||
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 a registered account |
|
||||
| `password` | string | Yes | Must match the account's password |
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"nick": "alice",
|
||||
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|---------|-------------|
|
||||
| `id` | integer | Session ID (same as when registered) |
|
||||
| `nick` | string | Current nick |
|
||||
| `token` | string | New 64-character hex auth token for this client |
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `nick and password required` | Missing nick or password |
|
||||
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
||||
-d '{"nick":"alice"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
```
|
||||
|
||||
@@ -1596,55 +1376,26 @@ Return server metadata. No authentication required.
|
||||
"name": "My NeoIRC Server",
|
||||
"version": "0.1.0",
|
||||
"motd": "Welcome! Be nice.",
|
||||
"users": 42,
|
||||
"hashcash_bits": 20
|
||||
"users": 42
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|---------|-------------|
|
||||
|-----------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
|
||||
|
||||
### GET /.well-known/healthcheck.json — Health Check
|
||||
|
||||
Standard health check endpoint. No authentication required. Returns server
|
||||
health status and runtime statistics.
|
||||
Standard health check endpoint. No authentication required.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"now": "2024-01-15T12:00:00.000000000Z",
|
||||
"uptimeSeconds": 3600,
|
||||
"uptimeHuman": "1h0m0s",
|
||||
"version": "0.1.0",
|
||||
"appname": "neoirc",
|
||||
"maintenanceMode": false,
|
||||
"sessions": 42,
|
||||
"clients": 85,
|
||||
"queuedLines": 128,
|
||||
"channels": 7,
|
||||
"connectionsSinceBoot": 200,
|
||||
"sessionsSinceBoot": 150,
|
||||
"messagesSinceBoot": 5000
|
||||
}
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| `sessions` | Current number of active sessions |
|
||||
| `clients` | Current number of connected clients |
|
||||
| `queuedLines` | Total entries in client output queues |
|
||||
| `channels` | Current number of channels |
|
||||
| `connectionsSinceBoot` | Total client connections since server start |
|
||||
| `sessionsSinceBoot` | Total sessions created since server start |
|
||||
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
|
||||
|
||||
---
|
||||
|
||||
## Message Flow
|
||||
@@ -1829,16 +1580,9 @@ authenticity.
|
||||
### Authentication
|
||||
|
||||
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
|
||||
Tokens are hashed (SHA-256) before storage and validated on every request.
|
||||
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
|
||||
password, instant access. The token is the sole credential.
|
||||
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
|
||||
(minimum 8 characters). The password is hashed with bcrypt at the default
|
||||
cost factor and stored alongside the session. `POST /api/v1/login`
|
||||
authenticates against the stored hash and issues a new client token.
|
||||
- **Password security**: Passwords are never stored in plain text. bcrypt
|
||||
handles salting and key stretching automatically. Anonymous sessions have
|
||||
an empty `password_hash` and cannot be logged into via `/login`.
|
||||
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.
|
||||
@@ -1986,26 +1730,13 @@ The database schema is managed via embedded SQL migration files in
|
||||
|
||||
**Current tables:**
|
||||
|
||||
#### `sessions`
|
||||
| Column | Type | Description |
|
||||
|----------------|----------|-------------|
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique session UUID |
|
||||
| `nick` | TEXT | Unique nick |
|
||||
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
|
||||
| `signing_key` | TEXT | Public signing key (empty string if unset) |
|
||||
| `away_message` | TEXT | Away message (empty string if not away) |
|
||||
| `created_at` | DATETIME | Session creation time |
|
||||
| `last_seen` | DATETIME | Last API request time |
|
||||
|
||||
#### `clients`
|
||||
#### `users`
|
||||
| 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 |
|
||||
| `nick` | TEXT | Unique nick |
|
||||
| `token` | TEXT | Unique auth token (64 hex chars) |
|
||||
| `created_at`| DATETIME | Session creation time |
|
||||
| `last_seen` | DATETIME | Last API request time |
|
||||
|
||||
#### `channels`
|
||||
@@ -2057,24 +1788,15 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
||||
|
||||
### Data Lifecycle
|
||||
|
||||
- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE`
|
||||
(default 30 days).
|
||||
- **Client output queue entries**: Pruned automatically when older than
|
||||
`QUEUE_MAX_AGE` (default 30 days).
|
||||
- **Messages**: Stored indefinitely in the current implementation. Rotation
|
||||
per `MAX_HISTORY` is planned.
|
||||
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
|
||||
planned.
|
||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
|
||||
or when the last client logs out (`POST /api/v1/logout` with no remaining
|
||||
clients triggers session cleanup). There is no distinction between session
|
||||
types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
||||
`DeleteSession` unconditionally. Idle sessions are automatically expired
|
||||
after `SESSION_IDLE_TIMEOUT`
|
||||
(default 30 days) — the server runs a background cleanup loop that parts
|
||||
idle users from all channels, broadcasts QUIT, and releases their nicks.
|
||||
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
|
||||
A session can have multiple clients; removing one doesn't affect others.
|
||||
However, when the last client is removed (via logout), the entire session
|
||||
is deleted — the user is parted from all channels, QUIT is broadcast, and
|
||||
the nick is released.
|
||||
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
|
||||
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
|
||||
24h) — the server runs a background cleanup loop that parts idle users
|
||||
from all channels, broadcasts QUIT, and releases their nicks.
|
||||
|
||||
---
|
||||
|
||||
@@ -2090,9 +1812,9 @@ directory is also loaded automatically via
|
||||
| `PORT` | int | `8080` | HTTP listen port |
|
||||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
|
||||
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
|
||||
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
|
||||
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. |
|
||||
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
|
||||
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
|
||||
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
|
||||
| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
|
||||
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
|
||||
| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) |
|
||||
| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` |
|
||||
@@ -2101,7 +1823,6 @@ directory is also loaded automatically via
|
||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
|
||||
### Example `.env` file
|
||||
@@ -2112,8 +1833,7 @@ SERVER_NAME=My NeoIRC Server
|
||||
MOTD=Welcome! Be excellent to each other.
|
||||
DEBUG=false
|
||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||
SESSION_IDLE_TIMEOUT=720h
|
||||
NEOIRC_HASHCASH_BITS=20
|
||||
SESSION_IDLE_TIMEOUT=24h
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2223,21 +1943,11 @@ A complete client needs only four HTTP calls:
|
||||
### Step-by-Step with curl
|
||||
|
||||
```bash
|
||||
# 1a. Create an anonymous session (no account)
|
||||
# 1. Create a session
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser"}' | jq -r .token)
|
||||
|
||||
# 1b. Or register an account (multi-client support)
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
||||
|
||||
# 1c. Or login to an existing account
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
||||
|
||||
# 2. Join a channel
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
@@ -2370,11 +2080,9 @@ Clients should handle these message commands from the queue:
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
|
||||
re-login (registered account).
|
||||
- **HTTP 401**: Token expired or invalid. Re-create session.
|
||||
- **HTTP 404**: Channel or user not found.
|
||||
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||
NICK change).
|
||||
- **HTTP 409**: Nick already taken (on session creation or NICK change).
|
||||
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
||||
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
||||
|
||||
@@ -2391,111 +2099,69 @@ 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 token is invalid.
|
||||
For anonymous sessions, create a new session. For registered accounts,
|
||||
log in again via `POST /api/v1/login` to get a fresh token on the same
|
||||
session. If it fails with a network error, retry with backoff.
|
||||
5. **Reconnection**: If the poll loop fails with 401, the session is gone.
|
||||
Create a new session. If it fails with a network error, retry with backoff.
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting & Abuse Prevention
|
||||
|
||||
### Hashcash Proof-of-Work
|
||||
|
||||
Session creation (`POST /api/v1/session`) requires a
|
||||
Session creation (`POST /api/v1/session`) will require a
|
||||
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
||||
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
||||
account registration, no IP-based rate limits that punish shared networks.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
|
||||
field (e.g., `20`) indicating the required difficulty.
|
||||
2. Client computes a hashcash stamp: find a counter value such that the
|
||||
SHA-256 hash of the stamp string has the required number of leading zero
|
||||
bits.
|
||||
3. Client includes the stamp in the `pow_token` field of the JSON request body when creating
|
||||
a session: `POST /api/v1/session`.
|
||||
4. Server validates the stamp:
|
||||
- Version is `1`
|
||||
- Claimed bits ≥ required bits
|
||||
- Resource matches the server name
|
||||
- Date is within 48 hours (not expired, not too far in the future)
|
||||
- SHA-256 hash has the required leading zero bits
|
||||
- Stamp has not been used before (replay prevention)
|
||||
|
||||
### Stamp Format
|
||||
|
||||
Standard hashcash format:
|
||||
|
||||
1. Client requests a challenge: `GET /api/v1/challenge`
|
||||
```json
|
||||
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
|
||||
```
|
||||
1:bits:date:resource::counter
|
||||
2. Server returns a nonce and a required difficulty (number of leading zero
|
||||
bits in the SHA-256 hash)
|
||||
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
|
||||
has the required number of leading zero bits:
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|------------|-------------|
|
||||
| `1` | Version (always `1`) |
|
||||
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
|
||||
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
|
||||
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
|
||||
| (empty) | Extension field (unused) |
|
||||
| `counter` | Hex counter value found by the client to satisfy the PoW |
|
||||
|
||||
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
|
||||
|
||||
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
|
||||
|
||||
### Computing a Stamp
|
||||
|
||||
```bash
|
||||
# Pseudocode
|
||||
bits = 20
|
||||
resource = "neoirc"
|
||||
date = "260310" # YYMMDD in UTC
|
||||
counter = 0
|
||||
|
||||
loop:
|
||||
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
|
||||
hash = SHA-256(stamp)
|
||||
if leading_zero_bits(hash) >= bits:
|
||||
return stamp
|
||||
counter++
|
||||
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
|
||||
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
|
||||
...
|
||||
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
|
||||
```
|
||||
4. Client submits the proof with the session request:
|
||||
```json
|
||||
POST /api/v1/session
|
||||
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
|
||||
```
|
||||
5. Server verifies:
|
||||
- Nonce was issued by this server and hasn't expired
|
||||
- Nonce hasn't been used before (prevent replay)
|
||||
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
|
||||
- If valid, create the session normally
|
||||
|
||||
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
|
||||
average, taking roughly 0.5–2 seconds on modern hardware.
|
||||
### Adaptive Difficulty
|
||||
|
||||
### Client Integration
|
||||
The required difficulty scales with server load. Under normal conditions, the
|
||||
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
|
||||
session creation rate increases, difficulty rises — making bulk session creation
|
||||
exponentially more expensive for attackers while remaining cheap for legitimate
|
||||
single-user connections.
|
||||
|
||||
Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||
| Server Load | Difficulty (bits) | Approx. Client CPU |
|
||||
|--------------------|-------------------|--------------------|
|
||||
| Normal (< 100/min) | 16 | ~1ms |
|
||||
| Elevated | 20 | ~15ms |
|
||||
| High | 24 | ~250ms |
|
||||
| Under attack | 28+ | ~4s+ |
|
||||
|
||||
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||
2. If `hashcash_bits > 0`, compute a valid stamp
|
||||
3. Include the stamp in the `pow_token` field of the JSON body on `POST /api/v1/session`
|
||||
|
||||
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
||||
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
|
||||
|
||||
| Value | Effect | Approx. Client CPU |
|
||||
|-------|--------|---------------------|
|
||||
| `0` | Disabled (no proof-of-work required) | — |
|
||||
| `16` | Light protection | ~1ms |
|
||||
| `20` | Default — good balance | ~0.5–2s |
|
||||
| `24` | Strong protection | ~10–30s |
|
||||
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||
|
||||
Each additional bit doubles the expected work. An attacker creating 1000
|
||||
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||
creating one session pays once and keeps their session.
|
||||
Each additional bit of difficulty doubles the expected work. An attacker
|
||||
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
|
||||
user creating one session needs ~4 seconds once and never again for the
|
||||
duration of their session.
|
||||
|
||||
### Why Hashcash and Not Rate Limits?
|
||||
|
||||
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
||||
The server only needs to verify a single hash.
|
||||
The server only needs to verify a hash.
|
||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||
campuses, corporate networks, Tor exits). Every client computes their own
|
||||
proof independently.
|
||||
@@ -2503,9 +2169,36 @@ creating one session pays once and keeps their session.
|
||||
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
||||
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
||||
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
||||
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
|
||||
once. A botnet trying to create thousands of sessions pays exponentially more.
|
||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||
The proof computation is trivially implementable in any client.
|
||||
|
||||
### Challenge Endpoint (Planned)
|
||||
|
||||
```
|
||||
GET /api/v1/challenge
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"nonce": "a1b2c3d4e5f6...",
|
||||
"difficulty": 20,
|
||||
"algorithm": "sha256",
|
||||
"expires": "2026-02-10T20:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|---------|-------------|
|
||||
| `nonce` | string | Server-generated random hex string (32+ chars) |
|
||||
| `difficulty` | integer | Required number of leading zero bits in the hash |
|
||||
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
|
||||
| `expires` | string | ISO 8601 expiry time for this challenge |
|
||||
|
||||
**Status:** Not yet implemented. Tracked for post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
@@ -2534,9 +2227,9 @@ creating one session pays once and keeps their session.
|
||||
|
||||
### Post-MVP (Planned)
|
||||
|
||||
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
||||
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
||||
- [x] **MODE command** — query channel and user modes (set not yet implemented)
|
||||
@@ -2587,18 +2280,15 @@ neoirc/
|
||||
├── cmd/
|
||||
│ ├── neoircd/ # Server binary entry point
|
||||
│ │ └── main.go
|
||||
│ └── neoirc-cli/ # TUI client entry point
|
||||
│ └── main.go # Minimal bootstrapping (calls internal/cli)
|
||||
│ └── neoirc-cli/ # TUI client
|
||||
│ ├── main.go # Command handling, poll loop
|
||||
│ ├── ui.go # tview-based terminal UI
|
||||
│ └── api/
|
||||
│ ├── client.go # HTTP API client library
|
||||
│ └── types.go # Request/response types
|
||||
├── internal/
|
||||
│ ├── broker/ # In-memory pub/sub for long-poll notifications
|
||||
│ │ └── broker.go
|
||||
│ ├── cli/ # TUI client implementation
|
||||
│ │ ├── app.go # App struct, command handling, poll loop
|
||||
│ │ ├── ui.go # tview-based terminal UI
|
||||
│ │ └── api/
|
||||
│ │ ├── client.go # HTTP API client library
|
||||
│ │ ├── types.go # Request/response types
|
||||
│ │ └── hashcash.go # Hashcash proof-of-work minting
|
||||
│ ├── config/ # Viper-based configuration
|
||||
│ │ └── config.go
|
||||
│ ├── db/ # Database access and migrations
|
||||
@@ -2614,8 +2304,6 @@ neoirc/
|
||||
│ │ └── healthcheck.go # Health check handler
|
||||
│ ├── healthcheck/ # Health check logic
|
||||
│ │ └── healthcheck.go
|
||||
│ ├── stats/ # Runtime statistics (atomic counters)
|
||||
│ │ └── stats.go
|
||||
│ ├── logger/ # slog-based logging
|
||||
│ │ └── logger.go
|
||||
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
|
||||
@@ -2648,7 +2336,7 @@ neoirc/
|
||||
| Purpose | Library |
|
||||
|------------|---------|
|
||||
| DI | `go.uber.org/fx` |
|
||||
| Router | `github.com/go-chi/chi` |
|
||||
| Router | `github.com/go-chi/chi/v5` |
|
||||
| Logging | `log/slog` (stdlib) |
|
||||
| Config | `github.com/spf13/viper` |
|
||||
| Env | `github.com/joho/godotenv/autoload` |
|
||||
@@ -2667,13 +2355,9 @@ neoirc/
|
||||
build a working IRC-style TUI client against this API in an afternoon, the
|
||||
API is too complex.
|
||||
|
||||
2. **Accounts 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), optional account registration
|
||||
with password is available — but never required. Identity
|
||||
verification at the message layer uses cryptographic signing,
|
||||
independent of account status.
|
||||
2. **No accounts** — identity is a signing key, nick is a display name. No
|
||||
registration, no passwords, no email verification. Session creation is
|
||||
instant. The cost of entry is a hashcash proof, not bureaucracy.
|
||||
|
||||
3. **IRC semantics over HTTP** — command names and numeric codes from
|
||||
RFC 1459/2812. If you've built an IRC client or bot, you already know the
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -43,30 +43,13 @@ func NewClient(baseURL string) *Client {
|
||||
}
|
||||
|
||||
// CreateSession creates a new session on the server.
|
||||
// If the server requires hashcash proof-of-work, it
|
||||
// automatically fetches the difficulty and computes a
|
||||
// valid stamp.
|
||||
func (client *Client) CreateSession(
|
||||
nick string,
|
||||
) (*SessionResponse, error) {
|
||||
// Fetch server info to check for hashcash requirement.
|
||||
info, err := client.GetServerInfo()
|
||||
|
||||
var hashcashStamp string
|
||||
|
||||
if err == nil && info.HashcashBits > 0 {
|
||||
resource := info.Name
|
||||
if resource == "" {
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
||||
}
|
||||
|
||||
data, err := client.do(
|
||||
http.MethodPost,
|
||||
"/api/v1/session",
|
||||
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
||||
&SessionRequest{Nick: nick},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -5,7 +5,6 @@ import "time"
|
||||
// SessionRequest is the body for POST /api/v1/session.
|
||||
type SessionRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// SessionResponse is the response from session creation.
|
||||
@@ -67,7 +66,6 @@ type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// MessagesResponse wraps polling results.
|
||||
@@ -1,8 +1,911 @@
|
||||
// Package main is the entry point for the neoirc-cli client.
|
||||
package main
|
||||
|
||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
splitParts = 2
|
||||
pollTimeout = 15
|
||||
pollRetry = 2 * time.Second
|
||||
timeFormat = "15:04"
|
||||
)
|
||||
|
||||
// App holds the application state.
|
||||
type App struct {
|
||||
ui *UI
|
||||
client *api.Client
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
target string
|
||||
connected bool
|
||||
lastQID int64
|
||||
stopPoll chan struct{}
|
||||
}
|
||||
|
||||
func main() {
|
||||
cli.Run()
|
||||
app := &App{ //nolint:exhaustruct
|
||||
ui: NewUI(),
|
||||
nick: "guest",
|
||||
}
|
||||
|
||||
app.ui.OnInput(app.handleInput)
|
||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||
|
||||
app.ui.AddStatus(
|
||||
"Welcome to neoirc-cli — an IRC-style client",
|
||||
)
|
||||
app.ui.AddStatus(
|
||||
"Type [yellow]/connect <server-url>" +
|
||||
"[white] to begin, " +
|
||||
"or [yellow]/help[white] for commands",
|
||||
)
|
||||
|
||||
err := app.ui.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleInput(text string) {
|
||||
if strings.HasPrefix(text, "/") {
|
||||
a.handleCommand(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus(
|
||||
"[red]Not connected. Use /connect <url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]No target. " +
|
||||
"Use /join #channel or /query nick",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(
|
||||
"[red]Send error: " + err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleCommand(text string) {
|
||||
parts := strings.SplitN(text, " ", splitParts)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = parts[1]
|
||||
}
|
||||
|
||||
a.dispatchCommand(cmd, args)
|
||||
}
|
||||
|
||||
func (a *App) dispatchCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/connect":
|
||||
a.cmdConnect(args)
|
||||
case "/nick":
|
||||
a.cmdNick(args)
|
||||
case "/join":
|
||||
a.cmdJoin(args)
|
||||
case "/part":
|
||||
a.cmdPart(args)
|
||||
case "/msg":
|
||||
a.cmdMsg(args)
|
||||
case "/query":
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
case "/window", "/w":
|
||||
a.cmdWindow(args)
|
||||
case "/quit":
|
||||
a.cmdQuit()
|
||||
case "/help":
|
||||
a.cmdHelp()
|
||||
default:
|
||||
a.dispatchInfoCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/motd":
|
||||
a.cmdMotd()
|
||||
case "/who":
|
||||
a.cmdWho(args)
|
||||
case "/whois":
|
||||
a.cmdWhois(args)
|
||||
default:
|
||||
a.ui.AddStatus(
|
||||
"[red]Unknown command: " + cmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdConnect(serverURL string) {
|
||||
if serverURL == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /connect <server-url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
client := api.NewClient(serverURL)
|
||||
|
||||
resp, err := client.CreateSession(nick)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Connection failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.client = client
|
||||
a.nick = resp.Nick
|
||||
a.connected = true
|
||||
a.lastQID = 0
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[green]Connected! Nick: %s, Session: %d",
|
||||
resp.Nick, resp.ID,
|
||||
))
|
||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||
|
||||
a.stopPoll = make(chan struct{})
|
||||
|
||||
go a.pollLoop()
|
||||
}
|
||||
|
||||
func (a *App) cmdNick(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /nick <name>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(
|
||||
"Nick set to " + nick +
|
||||
" (will be used on connect)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Nick change failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(nick, target, "connected")
|
||||
a.ui.AddStatus("Nick changed to " + nick)
|
||||
}
|
||||
|
||||
func (a *App) cmdJoin(channel string) {
|
||||
if channel == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /join #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.JoinChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Join failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = channel
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(channel)
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Joined "+channel,
|
||||
)
|
||||
a.ui.SetStatus(nick, channel, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdPart(channel string) {
|
||||
a.mu.Lock()
|
||||
if channel == "" {
|
||||
channel = a.target
|
||||
}
|
||||
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus("[red]No channel to part")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.PartChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Part failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Left "+channel,
|
||||
)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.target == channel {
|
||||
a.target = ""
|
||||
}
|
||||
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchBuffer(0)
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdMsg(args string) {
|
||||
parts := strings.SplitN(args, " ", splitParts)
|
||||
if len(parts) < splitParts {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /msg <nick> <text>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, text := parts[0], parts[1]
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Send failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdQuery(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /query <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = nick
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(nick)
|
||||
a.ui.SetStatus(myNick, nick, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdTopic(args string) {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic query failed: %v", err,
|
||||
))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic set failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdNames() {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
members, err := a.client.GetMembers(target)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Names failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[cyan]*** Members of %s: %s",
|
||||
target, strings.Join(members, " "),
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdList() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := a.client.ListChannels()
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]List failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||
|
||||
for _, ch := range channels {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
" %s (%d members) %s",
|
||||
ch.Name, ch.Members, ch.Topic,
|
||||
))
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||
}
|
||||
|
||||
func (a *App) cmdMotd() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]MOTD failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWho(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := args
|
||||
if channel == "" {
|
||||
channel = target
|
||||
}
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /who #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWho, To: channel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHO failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWhois(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /whois <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWhois, To: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHOIS failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWindow(args string) {
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /window <number>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var bufIndex int
|
||||
|
||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||
|
||||
a.ui.SwitchBuffer(bufIndex)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||
buf := a.ui.buffers[bufIndex]
|
||||
if buf.Name != "(status)" {
|
||||
a.mu.Lock()
|
||||
a.target = buf.Name
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(
|
||||
nick, buf.Name, "connected",
|
||||
)
|
||||
} else {
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdQuit() {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
if a.stopPoll != nil {
|
||||
close(a.stopPoll)
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
a.ui.Stop()
|
||||
}
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
" /part [#chan] — Leave channel",
|
||||
" /msg <nick> <text> — Send DM",
|
||||
" /query <nick> — Open DM window",
|
||||
" /topic [text] — View/set topic",
|
||||
" /names — List channel members",
|
||||
" /list — List channels",
|
||||
" /who [#channel] — List users in channel",
|
||||
" /whois <nick> — Show user info",
|
||||
" /motd — Show message of the day",
|
||||
" /window <n> — Switch buffer",
|
||||
" /quit — Disconnect and exit",
|
||||
" /help — This help",
|
||||
" Plain text sends to current target.",
|
||||
}
|
||||
|
||||
for _, line := range help {
|
||||
a.ui.AddStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
// pollLoop long-polls for messages in the background.
|
||||
func (a *App) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopPoll:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
client := a.client
|
||||
lastQID := a.lastQID
|
||||
a.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.PollMessages(
|
||||
lastQID, pollTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
time.Sleep(pollRetry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if result.LastID > 0 {
|
||||
a.mu.Lock()
|
||||
a.lastQID = result.LastID
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
for i := range result.Messages {
|
||||
a.handleServerMessage(&result.Messages[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleServerMessage(msg *api.Message) {
|
||||
timestamp := a.formatTS(msg)
|
||||
|
||||
a.mu.Lock()
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) formatTS(msg *api.Message) string {
|
||||
if msg.TS != "" {
|
||||
return msg.ParseTS().UTC().Format(timeFormat)
|
||||
}
|
||||
|
||||
return time.Now().Format(timeFormat)
|
||||
}
|
||||
|
||||
func (a *App) handlePrivmsgEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if msg.From == myNick {
|
||||
return
|
||||
}
|
||||
|
||||
target := msg.To
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
target = msg.From
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleJoinEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has joined %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handlePartEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||
timestamp, msg.From, msg.To, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleQuitEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||
timestamp, msg.From, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit",
|
||||
timestamp, msg.From,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleNickEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
|
||||
newNick := ""
|
||||
if len(lines) > 0 {
|
||||
newNick = lines[0]
|
||||
}
|
||||
|
||||
if msg.From == myNick && newNick != "" {
|
||||
a.mu.Lock()
|
||||
a.nick = newNick
|
||||
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(newNick, target, "connected")
|
||||
}
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s is now known as %s",
|
||||
timestamp, msg.From, newNick,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleNoticeEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [magenta]--%s-- %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleTopicEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [cyan]*** %s set topic: %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if text != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [white][%s] %s",
|
||||
timestamp, msg.Command, text,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -36,7 +35,6 @@ func main() {
|
||||
server.New,
|
||||
middleware.New,
|
||||
healthcheck.New,
|
||||
stats.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server) {}),
|
||||
).Run()
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package neoircapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
// bitsPerByte is the number of bits in a byte.
|
||||
bitsPerByte = 8
|
||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||
fullByteMask = 0xFF
|
||||
// counterSpace is the range for random counter seeds.
|
||||
counterSpace = 1 << 48
|
||||
)
|
||||
|
||||
// MintHashcash computes a hashcash stamp with the given
|
||||
// difficulty (leading zero bits) and resource string.
|
||||
func MintHashcash(bits int, resource string) string {
|
||||
date := time.Now().UTC().Format("060102")
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s::", bits, date, resource,
|
||||
)
|
||||
|
||||
for {
|
||||
counter := randomCounter()
|
||||
stamp := prefix + counter
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
hash []byte,
|
||||
numBits int,
|
||||
) bool {
|
||||
fullBytes := numBits / bitsPerByte
|
||||
remainBits := numBits % bitsPerByte
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(
|
||||
fullByteMask << (bitsPerByte - remainBits),
|
||||
)
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// randomCounter generates a random hex counter string.
|
||||
func randomCounter() string {
|
||||
counterVal, err := rand.Int(
|
||||
rand.Reader, big.NewInt(counterSpace),
|
||||
)
|
||||
if err != nil {
|
||||
// Fallback to timestamp-based counter on error.
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return hex.EncodeToString(counterVal.Bytes())
|
||||
}
|
||||
@@ -1,912 +0,0 @@
|
||||
// Package cli implements the neoirc-cli terminal client.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
splitParts = 2
|
||||
pollTimeout = 15
|
||||
pollRetry = 2 * time.Second
|
||||
timeFormat = "15:04"
|
||||
)
|
||||
|
||||
// App holds the application state.
|
||||
type App struct {
|
||||
ui *UI
|
||||
client *api.Client
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
target string
|
||||
connected bool
|
||||
lastQID int64
|
||||
stopPoll chan struct{}
|
||||
}
|
||||
|
||||
// Run creates and runs the CLI application.
|
||||
func Run() {
|
||||
app := &App{ //nolint:exhaustruct
|
||||
ui: NewUI(),
|
||||
nick: "guest",
|
||||
}
|
||||
|
||||
app.ui.OnInput(app.handleInput)
|
||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||
|
||||
app.ui.AddStatus(
|
||||
"Welcome to neoirc-cli — an IRC-style client",
|
||||
)
|
||||
app.ui.AddStatus(
|
||||
"Type [yellow]/connect <server-url>" +
|
||||
"[white] to begin, " +
|
||||
"or [yellow]/help[white] for commands",
|
||||
)
|
||||
|
||||
err := app.ui.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleInput(text string) {
|
||||
if strings.HasPrefix(text, "/") {
|
||||
a.handleCommand(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus(
|
||||
"[red]Not connected. Use /connect <url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]No target. " +
|
||||
"Use /join #channel or /query nick",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(
|
||||
"[red]Send error: " + err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleCommand(text string) {
|
||||
parts := strings.SplitN(text, " ", splitParts)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = parts[1]
|
||||
}
|
||||
|
||||
a.dispatchCommand(cmd, args)
|
||||
}
|
||||
|
||||
func (a *App) dispatchCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/connect":
|
||||
a.cmdConnect(args)
|
||||
case "/nick":
|
||||
a.cmdNick(args)
|
||||
case "/join":
|
||||
a.cmdJoin(args)
|
||||
case "/part":
|
||||
a.cmdPart(args)
|
||||
case "/msg":
|
||||
a.cmdMsg(args)
|
||||
case "/query":
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
case "/window", "/w":
|
||||
a.cmdWindow(args)
|
||||
case "/quit":
|
||||
a.cmdQuit()
|
||||
case "/help":
|
||||
a.cmdHelp()
|
||||
default:
|
||||
a.dispatchInfoCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/motd":
|
||||
a.cmdMotd()
|
||||
case "/who":
|
||||
a.cmdWho(args)
|
||||
case "/whois":
|
||||
a.cmdWhois(args)
|
||||
default:
|
||||
a.ui.AddStatus(
|
||||
"[red]Unknown command: " + cmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdConnect(serverURL string) {
|
||||
if serverURL == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /connect <server-url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
client := api.NewClient(serverURL)
|
||||
|
||||
resp, err := client.CreateSession(nick)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Connection failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.client = client
|
||||
a.nick = resp.Nick
|
||||
a.connected = true
|
||||
a.lastQID = 0
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[green]Connected! Nick: %s, Session: %d",
|
||||
resp.Nick, resp.ID,
|
||||
))
|
||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||
|
||||
a.stopPoll = make(chan struct{})
|
||||
|
||||
go a.pollLoop()
|
||||
}
|
||||
|
||||
func (a *App) cmdNick(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /nick <name>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(
|
||||
"Nick set to " + nick +
|
||||
" (will be used on connect)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Nick change failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(nick, target, "connected")
|
||||
a.ui.AddStatus("Nick changed to " + nick)
|
||||
}
|
||||
|
||||
func (a *App) cmdJoin(channel string) {
|
||||
if channel == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /join #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.JoinChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Join failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = channel
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(channel)
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Joined "+channel,
|
||||
)
|
||||
a.ui.SetStatus(nick, channel, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdPart(channel string) {
|
||||
a.mu.Lock()
|
||||
if channel == "" {
|
||||
channel = a.target
|
||||
}
|
||||
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus("[red]No channel to part")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.PartChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Part failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Left "+channel,
|
||||
)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.target == channel {
|
||||
a.target = ""
|
||||
}
|
||||
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchBuffer(0)
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdMsg(args string) {
|
||||
parts := strings.SplitN(args, " ", splitParts)
|
||||
if len(parts) < splitParts {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /msg <nick> <text>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, text := parts[0], parts[1]
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Send failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdQuery(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /query <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = nick
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(nick)
|
||||
a.ui.SetStatus(myNick, nick, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdTopic(args string) {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic query failed: %v", err,
|
||||
))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic set failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdNames() {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
members, err := a.client.GetMembers(target)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Names failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[cyan]*** Members of %s: %s",
|
||||
target, strings.Join(members, " "),
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdList() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := a.client.ListChannels()
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]List failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||
|
||||
for _, ch := range channels {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
" %s (%d members) %s",
|
||||
ch.Name, ch.Members, ch.Topic,
|
||||
))
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||
}
|
||||
|
||||
func (a *App) cmdMotd() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]MOTD failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWho(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := args
|
||||
if channel == "" {
|
||||
channel = target
|
||||
}
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /who #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWho, To: channel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHO failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWhois(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /whois <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWhois, To: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHOIS failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWindow(args string) {
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /window <number>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var bufIndex int
|
||||
|
||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||
|
||||
a.ui.SwitchBuffer(bufIndex)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||
buf := a.ui.buffers[bufIndex]
|
||||
if buf.Name != "(status)" {
|
||||
a.mu.Lock()
|
||||
a.target = buf.Name
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(
|
||||
nick, buf.Name, "connected",
|
||||
)
|
||||
} else {
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdQuit() {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
if a.stopPoll != nil {
|
||||
close(a.stopPoll)
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
a.ui.Stop()
|
||||
}
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
" /part [#chan] — Leave channel",
|
||||
" /msg <nick> <text> — Send DM",
|
||||
" /query <nick> — Open DM window",
|
||||
" /topic [text] — View/set topic",
|
||||
" /names — List channel members",
|
||||
" /list — List channels",
|
||||
" /who [#channel] — List users in channel",
|
||||
" /whois <nick> — Show user info",
|
||||
" /motd — Show message of the day",
|
||||
" /window <n> — Switch buffer",
|
||||
" /quit — Disconnect and exit",
|
||||
" /help — This help",
|
||||
" Plain text sends to current target.",
|
||||
}
|
||||
|
||||
for _, line := range help {
|
||||
a.ui.AddStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
// pollLoop long-polls for messages in the background.
|
||||
func (a *App) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopPoll:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
client := a.client
|
||||
lastQID := a.lastQID
|
||||
a.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.PollMessages(
|
||||
lastQID, pollTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
time.Sleep(pollRetry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if result.LastID > 0 {
|
||||
a.mu.Lock()
|
||||
a.lastQID = result.LastID
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
for i := range result.Messages {
|
||||
a.handleServerMessage(&result.Messages[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleServerMessage(msg *api.Message) {
|
||||
timestamp := a.formatTS(msg)
|
||||
|
||||
a.mu.Lock()
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) formatTS(msg *api.Message) string {
|
||||
if msg.TS != "" {
|
||||
return msg.ParseTS().UTC().Format(timeFormat)
|
||||
}
|
||||
|
||||
return time.Now().Format(timeFormat)
|
||||
}
|
||||
|
||||
func (a *App) handlePrivmsgEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if msg.From == myNick {
|
||||
return
|
||||
}
|
||||
|
||||
target := msg.To
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
target = msg.From
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleJoinEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has joined %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handlePartEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||
timestamp, msg.From, msg.To, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleQuitEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||
timestamp, msg.From, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit",
|
||||
timestamp, msg.From,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleNickEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
|
||||
newNick := ""
|
||||
if len(lines) > 0 {
|
||||
newNick = lines[0]
|
||||
}
|
||||
|
||||
if msg.From == myNick && newNick != "" {
|
||||
a.mu.Lock()
|
||||
a.nick = newNick
|
||||
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(newNick, target, "connected")
|
||||
}
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s is now known as %s",
|
||||
timestamp, msg.From, newNick,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleNoticeEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [magenta]--%s-- %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleTopicEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [cyan]*** %s set topic: %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if text != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [white][%s] %s",
|
||||
timestamp, msg.Command, text,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -38,14 +38,12 @@ type Config struct {
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
MessageMaxAge string
|
||||
MaxHistory int
|
||||
MaxMessageSize int
|
||||
QueueMaxAge string
|
||||
MOTD string
|
||||
ServerName string
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
HashcashBits int
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -70,14 +68,12 @@ func New(
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
|
||||
viper.SetDefault("MAX_HISTORY", "10000")
|
||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||
viper.SetDefault("QUEUE_MAX_AGE", "720h")
|
||||
viper.SetDefault("MOTD", defaultMOTD)
|
||||
viper.SetDefault("SERVER_NAME", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
@@ -96,14 +92,12 @@ func New(
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
|
||||
MOTD: viper.GetString("MOTD"),
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -746,8 +746,8 @@ func scanMessages(
|
||||
code, _ := strconv.Atoi(msg.Command)
|
||||
msg.Code = code
|
||||
|
||||
if mt, err := irc.FromInt(code); err == nil {
|
||||
msg.Command = mt.Name()
|
||||
if name := irc.Name(code); name != "" {
|
||||
msg.Command = name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,306 +1109,3 @@ 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(
|
||||
ctx context.Context,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
"DELETE FROM client_queues WHERE created_at < ?",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"prune old client output queue entries: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// PruneOldMessages deletes messages older than cutoff and
|
||||
// returns the number of rows removed.
|
||||
func (database *Database) PruneOldMessages(
|
||||
ctx context.Context,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
"DELETE FROM messages WHERE created_at < ?",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"prune old messages: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
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,7 +8,6 @@ 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
|
||||
);
|
||||
@@ -31,9 +30,6 @@ 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
|
||||
);
|
||||
@@ -62,14 +58,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -12,16 +11,10 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
errHashcashRequired = errors.New("hashcash required")
|
||||
errHashcashReused = errors.New("hashcash reused")
|
||||
)
|
||||
|
||||
var validNickRe = regexp.MustCompile(
|
||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||
)
|
||||
@@ -78,10 +71,11 @@ func (hdlr *Handlers) requireAuth(
|
||||
sessionID, clientID, nick, err :=
|
||||
hdlr.authSession(request)
|
||||
if err != nil {
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"error": "not registered",
|
||||
"numeric": irc.ErrNotRegistered,
|
||||
}, http.StatusUnauthorized)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"unauthorized",
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return 0, 0, "", false
|
||||
}
|
||||
@@ -95,11 +89,10 @@ func (hdlr *Handlers) fanOut(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (string, error) {
|
||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||
request.Context(), command, from, target, nil, body, meta,
|
||||
request.Context(), command, from, target, nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert message: %w", err)
|
||||
@@ -125,11 +118,10 @@ func (hdlr *Handlers) fanOutSilent(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) error {
|
||||
_, err := hdlr.fanOut(
|
||||
request, command, from, target, body, meta, sessionIDs,
|
||||
request, command, from, target, body, sessionIDs,
|
||||
)
|
||||
|
||||
return err
|
||||
@@ -155,7 +147,6 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
) {
|
||||
type createRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
var payload createRequest
|
||||
@@ -171,32 +162,6 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
return
|
||||
}
|
||||
|
||||
// Validate hashcash proof-of-work if configured.
|
||||
if hdlr.params.Config.HashcashBits > 0 {
|
||||
if payload.Hashcash == "" {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"hashcash proof-of-work required",
|
||||
http.StatusPaymentRequired,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = hdlr.hashcashVal.Validate(
|
||||
payload.Hashcash, hdlr.params.Config.HashcashBits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid hashcash stamp: "+err.Error(),
|
||||
http.StatusPaymentRequired,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||
|
||||
if !validNickRe.MatchString(payload.Nick) {
|
||||
@@ -221,9 +186,6 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrSessions()
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
@@ -303,7 +265,7 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
[]string{
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
"CHANMODES=,,H," + "imnst",
|
||||
"CHANMODES=,,," + "imnst",
|
||||
"NETWORK=neoirc",
|
||||
"CASEMAPPING=ascii",
|
||||
},
|
||||
@@ -436,12 +398,12 @@ func (hdlr *Handlers) serverName() string {
|
||||
func (hdlr *Handlers) enqueueNumeric(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
code irc.IRCMessageType,
|
||||
code int,
|
||||
nick string,
|
||||
params []string,
|
||||
text string,
|
||||
) {
|
||||
command := code.Code()
|
||||
command := fmt.Sprintf("%03d", code)
|
||||
|
||||
body, err := json.Marshal([]string{text})
|
||||
if err != nil {
|
||||
@@ -834,7 +796,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
payload.Command, payload.To,
|
||||
payload.Body, payload.Meta, bodyLines,
|
||||
payload.Body, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -845,20 +807,14 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdAway:
|
||||
hdlr.handleAway(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||
hdlr.handlePrivmsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, meta, bodyLines,
|
||||
command, target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdJoin:
|
||||
hdlr.handleJoin(
|
||||
@@ -959,14 +915,13 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNoRecipient, nick, []string{command},
|
||||
"No recipient given",
|
||||
irc.ErrNeedMoreParams, nick, []string{command},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -980,8 +935,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
if len(lines) == 0 {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNoTextToSend, nick, []string{command},
|
||||
"No text to send",
|
||||
irc.ErrNeedMoreParams, nick, []string{command},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -991,13 +946,11 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrMessages()
|
||||
|
||||
if strings.HasPrefix(target, "#") {
|
||||
hdlr.handleChannelMsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, meta,
|
||||
command, target, body,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1006,7 +959,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
hdlr.handleDirectMsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, meta,
|
||||
command, target, body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1016,7 +969,7 @@ func (hdlr *Handlers) respondIRCError(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
code irc.IRCMessageType,
|
||||
code int,
|
||||
nick string,
|
||||
params []string,
|
||||
text string,
|
||||
@@ -1037,7 +990,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
request.Context(), target,
|
||||
@@ -1071,187 +1023,23 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
if !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Cannot send to channel",
|
||||
irc.ErrNotOnChannel, nick, []string{target},
|
||||
"You're not on that channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hashcashErr := hdlr.validateChannelHashcash(
|
||||
request, clientID, sessionID,
|
||||
writer, nick, target, body, meta, chID,
|
||||
)
|
||||
if hashcashErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.sendChannelMsg(
|
||||
writer, request, command, nick, target,
|
||||
body, meta, chID,
|
||||
writer, request, command, nick, target, body, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// validateChannelHashcash checks whether the channel
|
||||
// requires hashcash proof-of-work for messages and
|
||||
// validates the stamp from the message meta field.
|
||||
// Returns nil on success or if the channel has no
|
||||
// hashcash requirement. On failure, it sends the
|
||||
// appropriate IRC error and returns a non-nil error.
|
||||
func (hdlr *Handlers) validateChannelHashcash(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
writer http.ResponseWriter,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
|
||||
ctx, chID,
|
||||
)
|
||||
if bitsErr != nil {
|
||||
hdlr.log.Error(
|
||||
"get channel hashcash bits", "error", bitsErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
|
||||
}
|
||||
|
||||
if bits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stamp := hdlr.extractHashcashFromMeta(meta)
|
||||
if stamp == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Channel requires hashcash proof-of-work",
|
||||
)
|
||||
|
||||
return errHashcashRequired
|
||||
}
|
||||
|
||||
return hdlr.verifyChannelStamp(
|
||||
request, writer,
|
||||
clientID, sessionID,
|
||||
nick, target, body, stamp, bits,
|
||||
)
|
||||
}
|
||||
|
||||
// verifyChannelStamp validates a channel hashcash stamp
|
||||
// and checks for replay attacks.
|
||||
func (hdlr *Handlers) verifyChannelStamp(
|
||||
request *http.Request,
|
||||
writer http.ResponseWriter,
|
||||
clientID, sessionID int64,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
stamp string,
|
||||
bits int,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
bodyHashStr := hashcash.BodyHash(body)
|
||||
|
||||
valErr := hdlr.channelHashcash.ValidateStamp(
|
||||
stamp, bits, target, bodyHashStr,
|
||||
)
|
||||
if valErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Invalid hashcash: "+valErr.Error(),
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash: %w", valErr)
|
||||
}
|
||||
|
||||
stampKey := hashcash.StampHash(stamp)
|
||||
|
||||
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if spentErr != nil {
|
||||
hdlr.log.Error(
|
||||
"check spent hashcash", "error", spentErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("check spent hashcash: %w", spentErr)
|
||||
}
|
||||
|
||||
if spent {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Hashcash stamp already used",
|
||||
)
|
||||
|
||||
return errHashcashReused
|
||||
}
|
||||
|
||||
recordErr := hdlr.params.Database.RecordSpentHashcash(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if recordErr != nil {
|
||||
hdlr.log.Error(
|
||||
"record spent hashcash", "error", recordErr,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHashcashFromMeta parses the meta JSON and
|
||||
// returns the hashcash stamp string, or empty string
|
||||
// if not present.
|
||||
func (hdlr *Handlers) extractHashcashFromMeta(
|
||||
meta json.RawMessage,
|
||||
) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var metaMap map[string]json.RawMessage
|
||||
|
||||
err := json.Unmarshal(meta, &metaMap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw, ok := metaMap["hashcash"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var stamp string
|
||||
|
||||
err = json.Unmarshal(raw, &stamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stamp
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) sendChannelMsg(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
command, nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) {
|
||||
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
||||
@@ -1271,7 +1059,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
||||
}
|
||||
|
||||
msgUUID, err := hdlr.fanOut(
|
||||
request, command, nick, target, body, meta, memberIDs,
|
||||
request, command, nick, target, body, memberIDs,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("send message failed", "error", err)
|
||||
@@ -1295,7 +1083,6 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
||||
request.Context(), target,
|
||||
@@ -1320,7 +1107,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
}
|
||||
|
||||
msgUUID, err := hdlr.fanOut(
|
||||
request, command, nick, target, body, meta, recipients,
|
||||
request, command, nick, target, body, recipients,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("send dm failed", "error", err)
|
||||
@@ -1333,19 +1120,6 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
return
|
||||
}
|
||||
|
||||
// If the target is away, send RPL_AWAY to the sender.
|
||||
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
||||
request.Context(), targetSID,
|
||||
)
|
||||
if awayErr == nil && awayMsg != "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplAway, nick,
|
||||
[]string{target}, awayMsg,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"id": msgUUID, "status": "sent"},
|
||||
http.StatusOK)
|
||||
@@ -1431,7 +1205,7 @@ func (hdlr *Handlers) executeJoin(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.deliverJoinNumerics(
|
||||
@@ -1456,25 +1230,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
hdlr.deliverTopicNumerics(
|
||||
ctx, clientID, sessionID, nick, channel, chID,
|
||||
chInfo, err := hdlr.params.Database.GetChannelByName(
|
||||
ctx, channel,
|
||||
)
|
||||
|
||||
hdlr.deliverNamesNumerics(
|
||||
ctx, clientID, nick, channel, chID,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
if err == nil {
|
||||
_ = chInfo // chInfo is the ID; topic comes from DB.
|
||||
}
|
||||
|
||||
// deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC,
|
||||
// plus RPL_TOPICWHOTIME when topic metadata is available.
|
||||
func (hdlr *Handlers) deliverTopicNumerics(
|
||||
ctx context.Context,
|
||||
clientID, sessionID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
// Get topic from channel info.
|
||||
channels, listErr := hdlr.params.Database.ListChannels(
|
||||
ctx, sessionID,
|
||||
)
|
||||
@@ -1496,39 +1259,14 @@ func (hdlr *Handlers) deliverTopicNumerics(
|
||||
ctx, clientID, irc.RplTopic, nick,
|
||||
[]string{channel}, topic,
|
||||
)
|
||||
|
||||
topicMeta, tmErr := hdlr.params.Database.
|
||||
GetTopicMeta(ctx, chID)
|
||||
if tmErr == nil && topicMeta != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID,
|
||||
irc.RplTopicWhoTime, nick,
|
||||
[]string{
|
||||
channel,
|
||||
topicMeta.SetBy,
|
||||
strconv.FormatInt(
|
||||
topicMeta.SetAt.Unix(), 10,
|
||||
),
|
||||
},
|
||||
"",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplNoTopic, nick,
|
||||
[]string{channel}, "No topic is set",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverNamesNumerics sends RPL_NAMREPLY and
|
||||
// RPL_ENDOFNAMES for a channel.
|
||||
func (hdlr *Handlers) deliverNamesNumerics(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
// Get member list for NAMES reply.
|
||||
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||
ctx, chID,
|
||||
)
|
||||
@@ -1551,6 +1289,8 @@ func (hdlr *Handlers) deliverNamesNumerics(
|
||||
ctx, clientID, irc.RplEndOfNames, nick,
|
||||
[]string{channel}, "End of /NAMES list",
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handlePart(
|
||||
@@ -1601,7 +1341,7 @@ func (hdlr *Handlers) handlePart(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
err = hdlr.params.Database.PartChannel(
|
||||
@@ -1818,32 +1558,6 @@ func (hdlr *Handlers) handleTopic(
|
||||
return
|
||||
}
|
||||
|
||||
isMember, err := hdlr.params.Database.IsChannelMember(
|
||||
request.Context(), chID, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"check membership failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNotOnChannel, nick, []string{channel},
|
||||
"You're not on that channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.executeTopic(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
@@ -1860,8 +1574,8 @@ func (hdlr *Handlers) executeTopic(
|
||||
body json.RawMessage,
|
||||
chID int64,
|
||||
) {
|
||||
setErr := hdlr.params.Database.SetTopicMeta(
|
||||
request.Context(), channel, topic, nick,
|
||||
setErr := hdlr.params.Database.SetTopic(
|
||||
request.Context(), channel, topic,
|
||||
)
|
||||
if setErr != nil {
|
||||
hdlr.log.Error(
|
||||
@@ -1881,32 +1595,13 @@ func (hdlr *Handlers) executeTopic(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplTopic, nick, []string{channel}, topic,
|
||||
)
|
||||
|
||||
// 333 RPL_TOPICWHOTIME
|
||||
topicMeta, tmErr := hdlr.params.Database.
|
||||
GetTopicMeta(request.Context(), chID)
|
||||
if tmErr == nil && topicMeta != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplTopicWhoTime, nick,
|
||||
[]string{
|
||||
channel,
|
||||
topicMeta.SetBy,
|
||||
strconv.FormatInt(
|
||||
topicMeta.SetAt.Unix(), 10,
|
||||
),
|
||||
},
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -2044,10 +1739,11 @@ func (hdlr *Handlers) handleMode(
|
||||
return
|
||||
}
|
||||
|
||||
_ = bodyLines
|
||||
|
||||
hdlr.handleChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel,
|
||||
bodyLines,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2056,7 +1752,6 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
@@ -2073,47 +1768,10 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) > 0 {
|
||||
hdlr.applyChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, lines,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.queryChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||
// RPL_CREATIONTIME for a channel. Includes +H if
|
||||
// the channel has a hashcash requirement.
|
||||
func (hdlr *Handlers) queryChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
modeStr := "+n"
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.
|
||||
GetChannelHashcashBits(ctx, chID)
|
||||
if bitsErr == nil && bits > 0 {
|
||||
modeStr = fmt.Sprintf("+nH %d", bits)
|
||||
}
|
||||
|
||||
// 324 RPL_CHANNELMODEIS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, modeStr}, "",
|
||||
[]string{channel, "+n"}, "",
|
||||
)
|
||||
|
||||
// 329 RPL_CREATIONTIME
|
||||
@@ -2138,156 +1796,6 @@ func (hdlr *Handlers) queryChannelMode(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// applyChannelMode handles setting channel modes.
|
||||
// Currently supports +H/-H for hashcash bits.
|
||||
func (hdlr *Handlers) applyChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
modeStr := modeArgs[0]
|
||||
|
||||
switch modeStr {
|
||||
case "+H":
|
||||
hdlr.setHashcashMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, modeArgs,
|
||||
)
|
||||
case "-H":
|
||||
hdlr.clearHashcashMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID,
|
||||
)
|
||||
default:
|
||||
// Unknown or unsupported mode change.
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.ErrUnknownMode, nick,
|
||||
[]string{modeStr},
|
||||
"is unknown mode char to me",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "error"},
|
||||
http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// minHashcashBits is the minimum allowed hashcash
|
||||
// difficulty for channels.
|
||||
minHashcashBits = 1
|
||||
// maxHashcashBits is the maximum allowed hashcash
|
||||
// difficulty for channels.
|
||||
maxHashcashBits = 40
|
||||
)
|
||||
|
||||
// setHashcashMode handles MODE #channel +H <bits>.
|
||||
func (hdlr *Handlers) setHashcashMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
||||
"Not enough parameters (+H requires bits)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bits, err := strconv.Atoi(modeArgs[1])
|
||||
if err != nil || bits < minHashcashBits ||
|
||||
bits > maxHashcashBits {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUnknownMode, nick, []string{"+H"},
|
||||
fmt.Sprintf(
|
||||
"Invalid hashcash bits (must be %d-%d)",
|
||||
minHashcashBits, maxHashcashBits,
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = hdlr.params.Database.SetChannelHashcashBits(
|
||||
ctx, chID, bits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set channel hashcash bits", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{
|
||||
channel,
|
||||
fmt.Sprintf("+H %d", bits),
|
||||
}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// clearHashcashMode handles MODE #channel -H.
|
||||
func (hdlr *Handlers) clearHashcashMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
err := hdlr.params.Database.SetChannelHashcashBits(
|
||||
ctx, chID, 0,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"clear channel hashcash bits", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, "+n"}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleNames sends NAMES reply for a channel.
|
||||
func (hdlr *Handlers) handleNames(
|
||||
writer http.ResponseWriter,
|
||||
@@ -2483,11 +1991,6 @@ func (hdlr *Handlers) executeWhois(
|
||||
"neoirc server",
|
||||
)
|
||||
|
||||
// 317 RPL_WHOISIDLE
|
||||
hdlr.deliverWhoisIdle(
|
||||
ctx, clientID, nick, queryNick, targetSID,
|
||||
)
|
||||
|
||||
// 319 RPL_WHOISCHANNELS
|
||||
hdlr.deliverWhoisChannels(
|
||||
ctx, clientID, nick, queryNick, targetSID,
|
||||
@@ -2889,111 +2392,11 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"name": hdlr.params.Config.ServerName,
|
||||
"version": hdlr.params.Globals.Version,
|
||||
"motd": hdlr.params.Config.MOTD,
|
||||
"users": users,
|
||||
}
|
||||
|
||||
if hdlr.params.Config.HashcashBits > 0 {
|
||||
resp["hashcash_bits"] = hdlr.params.Config.HashcashBits
|
||||
}
|
||||
|
||||
hdlr.respondJSON(
|
||||
writer, request, resp, http.StatusOK,
|
||||
)
|
||||
}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAway handles the AWAY command. An empty body
|
||||
// clears the away status; a non-empty body sets it.
|
||||
func (hdlr *Handlers) handleAway(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
lines := bodyLines()
|
||||
|
||||
awayMsg := ""
|
||||
if len(lines) > 0 {
|
||||
awayMsg = strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
err := hdlr.params.Database.SetAway(
|
||||
ctx, sessionID, awayMsg,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("set away failed", "error", err)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if awayMsg == "" {
|
||||
// 305 RPL_UNAWAY
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUnaway, nick, nil,
|
||||
"You are no longer marked as being away",
|
||||
)
|
||||
} else {
|
||||
// 306 RPL_NOWAWAY
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplNowAway, nick, nil,
|
||||
"You have been marked as being away",
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
|
||||
// time and signon time.
|
||||
func (hdlr *Handlers) deliverWhoisIdle(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
nick, queryNick string,
|
||||
targetSID int64,
|
||||
) {
|
||||
lastSeen, lsErr := hdlr.params.Database.
|
||||
GetSessionLastSeen(ctx, targetSID)
|
||||
if lsErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
createdAt, caErr := hdlr.params.Database.
|
||||
GetSessionCreatedAt(ctx, targetSID)
|
||||
if caErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
idleSeconds := int64(time.Since(lastSeen).Seconds())
|
||||
if idleSeconds < 0 {
|
||||
idleSeconds = 0
|
||||
}
|
||||
|
||||
signonUnix := strconv.FormatInt(
|
||||
createdAt.Unix(), 10,
|
||||
)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWhoisIdle, nick,
|
||||
[]string{
|
||||
queryNick,
|
||||
strconv.FormatInt(idleSeconds, 10),
|
||||
signonUnix,
|
||||
},
|
||||
"seconds idle, signon time",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,10 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"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/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
@@ -87,12 +85,10 @@ func newTestServer(
|
||||
|
||||
cfg.DBURL = dbURL
|
||||
cfg.Port = 0
|
||||
cfg.HashcashBits = 0
|
||||
|
||||
return cfg, nil
|
||||
},
|
||||
newTestDB,
|
||||
stats.New,
|
||||
newTestHealthcheck,
|
||||
newTestMiddleware,
|
||||
newTestHandlers,
|
||||
@@ -147,14 +143,12 @@ func newTestHealthcheck(
|
||||
cfg *config.Config,
|
||||
log *logger.Logger,
|
||||
database *db.Database,
|
||||
tracker *stats.Tracker,
|
||||
) (*healthcheck.Healthcheck, error) {
|
||||
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
||||
Globals: globs,
|
||||
Config: cfg,
|
||||
Logger: log,
|
||||
Database: database,
|
||||
Stats: tracker,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("test healthcheck: %w", err)
|
||||
@@ -188,7 +182,6 @@ func newTestHandlers(
|
||||
cfg *config.Config,
|
||||
database *db.Database,
|
||||
hcheck *healthcheck.Healthcheck,
|
||||
tracker *stats.Tracker,
|
||||
) (*handlers.Handlers, error) {
|
||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||
Logger: log,
|
||||
@@ -196,7 +189,6 @@ func newTestHandlers(
|
||||
Config: cfg,
|
||||
Database: database,
|
||||
Healthcheck: hcheck,
|
||||
Stats: tracker,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("test handlers: %w", err)
|
||||
@@ -818,9 +810,9 @@ func TestMessageMissingBody(t *testing.T) {
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "412") {
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTEXTTOSEND (412), got %v",
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
@@ -842,9 +834,9 @@ func TestMessageMissingTo(t *testing.T) {
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "411") {
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NORECIPIENT (411), got %v",
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
@@ -877,9 +869,9 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
|
||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "404") {
|
||||
if !findNumeric(msgs, "442") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
@@ -1141,42 +1133,6 @@ func TestTopicMissingBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicNonMember(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
aliceToken := tserver.createSession("alice_topic")
|
||||
bobToken := tserver.createSession("bob_topic")
|
||||
|
||||
// Only alice joins the channel.
|
||||
tserver.sendCommand(aliceToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#topicpriv",
|
||||
})
|
||||
|
||||
// Drain bob's initial messages.
|
||||
_, lastID := tserver.pollMessages(bobToken, 0)
|
||||
|
||||
// Bob tries to set topic without joining.
|
||||
status, _ := tserver.sendCommand(
|
||||
bobToken,
|
||||
map[string]any{
|
||||
commandKey: "TOPIC",
|
||||
toKey: "#topicpriv",
|
||||
bodyKey: []string{"Hijacked topic"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "442") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("ping_user")
|
||||
@@ -1700,133 +1656,6 @@ func TestHealthcheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url("/.well-known/healthcheck.json"),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
if decErr != nil {
|
||||
t.Fatalf("decode healthcheck: %v", decErr)
|
||||
}
|
||||
|
||||
requiredFields := []string{
|
||||
"sessions", "clients", "queuedLines",
|
||||
"channels", "connectionsSinceBoot",
|
||||
"sessionsSinceBoot", "messagesSinceBoot",
|
||||
}
|
||||
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf(
|
||||
"missing field %q in healthcheck", field,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("statsuser")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#statschan",
|
||||
})
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#statschan",
|
||||
bodyKey: []string{"hello stats"},
|
||||
})
|
||||
|
||||
result := tserver.fetchHealthcheck(t)
|
||||
|
||||
assertFieldGTE(t, result, "sessions", 1)
|
||||
assertFieldGTE(t, result, "clients", 1)
|
||||
assertFieldGTE(t, result, "channels", 1)
|
||||
assertFieldGTE(t, result, "queuedLines", 0)
|
||||
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
|
||||
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
|
||||
assertFieldGTE(t, result, "messagesSinceBoot", 1)
|
||||
}
|
||||
|
||||
func (tserver *testServer) fetchHealthcheck(
|
||||
t *testing.T,
|
||||
) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url("/.well-known/healthcheck.json"),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
if decErr != nil {
|
||||
t.Fatalf("decode healthcheck: %v", decErr)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func assertFieldGTE(
|
||||
t *testing.T,
|
||||
result map[string]any,
|
||||
field string,
|
||||
minimum float64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
val, ok := result[field].(float64)
|
||||
if !ok {
|
||||
t.Errorf(
|
||||
"field %q: not a number (got %T)",
|
||||
field, result[field],
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if val < minimum {
|
||||
t.Errorf(
|
||||
"expected %s >= %v, got %v",
|
||||
field, minimum, val,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterValid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
@@ -2158,397 +1987,3 @@ func TestNickBroadcastToChannels(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Channel Hashcash Tests ---
|
||||
|
||||
const (
|
||||
metaKey = "meta"
|
||||
modeCmd = "MODE"
|
||||
hashcashKey = "hashcash"
|
||||
)
|
||||
|
||||
func mintTestChannelHashcash(
|
||||
tb testing.TB,
|
||||
bits int,
|
||||
channel string,
|
||||
body json.RawMessage,
|
||||
) string {
|
||||
tb.Helper()
|
||||
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
return hashcash.MintChannelStamp(bits, channel, bodyHash)
|
||||
}
|
||||
|
||||
func TestChannelHashcashSetMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcmode_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hctest",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Set hashcash bits to 2 via MODE +H.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hctest",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get RPL_CHANNELMODEIS (324) confirming +H.
|
||||
if !findNumeric(msgs, "324") {
|
||||
t.Fatalf(
|
||||
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashQueryMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcquery_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcquery",
|
||||
})
|
||||
|
||||
// Set hashcash bits.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcquery",
|
||||
bodyKey: []string{"+H", "5"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Query mode — should show +nH.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcquery",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
found := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if ok && int(code) == 324 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatalf(
|
||||
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashClearMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcclear_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcclear",
|
||||
})
|
||||
|
||||
// Set hashcash bits.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"+H", "5"},
|
||||
})
|
||||
|
||||
// Clear hashcash bits.
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"-H"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
// Now message should succeed without hashcash.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"test message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashRejectNoStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcreject_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcreject",
|
||||
})
|
||||
|
||||
// Set hashcash requirement.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcreject",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Send message without hashcash — should fail.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreject",
|
||||
bodyKey: []string{"spam message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcaccept_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcaccept",
|
||||
})
|
||||
|
||||
// Set hashcash requirement (2 bits = fast to mint).
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcaccept",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Mint a valid hashcash stamp.
|
||||
msgBody, marshalErr := json.Marshal(
|
||||
[]string{"hello world"},
|
||||
)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
|
||||
stamp := mintTestChannelHashcash(
|
||||
t, 2, "#hcaccept", msgBody,
|
||||
)
|
||||
|
||||
// Send message with valid hashcash.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcaccept",
|
||||
bodyKey: []string{"hello world"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result["id"] == nil || result["id"] == "" {
|
||||
t.Fatal("expected message id for valid hashcash")
|
||||
}
|
||||
|
||||
// Verify the message was delivered.
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
|
||||
t.Fatalf(
|
||||
"message not received: %v", msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcreplay_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcreplay",
|
||||
})
|
||||
|
||||
// Set hashcash requirement.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, _ = tserver.pollMessages(token, 0)
|
||||
|
||||
// Mint and send once — should succeed.
|
||||
msgBody, marshalErr := json.Marshal(
|
||||
[]string{"unique msg"},
|
||||
)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
|
||||
stamp := mintTestChannelHashcash(
|
||||
t, 2, "#hcreplay", msgBody,
|
||||
)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"unique msg"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Replay the same stamp — should fail.
|
||||
status, _ = tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"unique msg"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected replay rejection (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcnone_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#nohashcash",
|
||||
})
|
||||
|
||||
// No hashcash set — message should work.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#nohashcash",
|
||||
bodyKey: []string{"free message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result["id"] == nil || result["id"] == "" {
|
||||
t.Fatal("expected message id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcbits_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcbits",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set bits to 0 — should fail.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcbits",
|
||||
bodyKey: []string{"+H", "0"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "472") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UNKNOWNMODE (472), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcnoarg_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcnoarg",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set +H without bits argument.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcnoarg",
|
||||
bodyKey: []string{"+H"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +82,6 @@ func (hdlr *Handlers) handleRegister(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrSessions()
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
@@ -183,8 +180,6 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
@@ -13,10 +13,8 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -31,15 +29,9 @@ 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
|
||||
const defaultIdleTimeout = 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
@@ -47,9 +39,6 @@ type Handlers struct {
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -58,19 +47,11 @@ func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
resource := params.Config.ServerName
|
||||
if resource == "" {
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
@@ -219,93 +200,4 @@ func (hdlr *Handlers) runCleanup(
|
||||
"deleted", deleted,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.pruneQueuesAndMessages(ctx)
|
||||
}
|
||||
|
||||
// parseDurationConfig parses a Go duration string,
|
||||
// returning zero on empty input and logging on error.
|
||||
func (hdlr *Handlers) parseDurationConfig(
|
||||
name, raw string,
|
||||
) time.Duration {
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
dur, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"invalid duration config, skipping",
|
||||
"name", name, "value", raw, "error", err,
|
||||
)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
return dur
|
||||
}
|
||||
|
||||
// pruneQueuesAndMessages removes old client output queue
|
||||
// entries per QUEUE_MAX_AGE and old messages per
|
||||
// MESSAGE_MAX_AGE.
|
||||
func (hdlr *Handlers) pruneQueuesAndMessages(
|
||||
ctx context.Context,
|
||||
) {
|
||||
queueMaxAge := hdlr.parseDurationConfig(
|
||||
"QUEUE_MAX_AGE",
|
||||
hdlr.params.Config.QueueMaxAge,
|
||||
)
|
||||
if queueMaxAge > 0 {
|
||||
queueCutoff := time.Now().Add(-queueMaxAge)
|
||||
|
||||
pruned, err := hdlr.params.Database.
|
||||
PruneOldQueueEntries(ctx, queueCutoff)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"client output queue pruning failed", "error", err,
|
||||
)
|
||||
} else if pruned > 0 {
|
||||
hdlr.log.Info(
|
||||
"pruned old client output queue entries",
|
||||
"deleted", pruned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
messageMaxAge := hdlr.parseDurationConfig(
|
||||
"MESSAGE_MAX_AGE",
|
||||
hdlr.params.Config.MessageMaxAge,
|
||||
)
|
||||
if messageMaxAge > 0 {
|
||||
msgCutoff := time.Now().Add(-messageMaxAge)
|
||||
|
||||
pruned, err := hdlr.params.Database.
|
||||
PruneOldMessages(ctx, msgCutoff)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"message pruning failed", "error", err,
|
||||
)
|
||||
} else if pruned > 0 {
|
||||
hdlr.log.Info(
|
||||
"pruned old messages",
|
||||
"deleted", pruned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(request.Context())
|
||||
resp := hdlr.hc.Healthcheck()
|
||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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++
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
// Package hashcash implements SHA-256-based hashcash
|
||||
// proof-of-work validation for abuse prevention.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
||||
//
|
||||
// The SHA-256 hash of the entire stamp string must have
|
||||
// at least `bits` leading zero bits.
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// stampVersion is the only supported hashcash version.
|
||||
stampVersion = "1"
|
||||
// stampFields is the number of fields in a stamp.
|
||||
stampFields = 6
|
||||
// maxStampAge is how old a stamp can be before
|
||||
// rejection.
|
||||
maxStampAge = 48 * time.Hour
|
||||
// maxFutureSkew allows stamps slightly in the future.
|
||||
maxFutureSkew = 1 * time.Hour
|
||||
// pruneInterval controls how often expired stamps are
|
||||
// removed from the spent set.
|
||||
pruneInterval = 10 * time.Minute
|
||||
// dateFormatShort is the YYMMDD date layout.
|
||||
dateFormatShort = "060102"
|
||||
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
||||
dateFormatLong = "060102150405"
|
||||
// dateShortLen is the length of YYMMDD.
|
||||
dateShortLen = 6
|
||||
// dateLongLen is the length of YYMMDDHHMMSS.
|
||||
dateLongLen = 12
|
||||
// bitsPerByte is the number of bits in a byte.
|
||||
bitsPerByte = 8
|
||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||
fullByteMask = 0xFF
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidFields = errors.New("invalid stamp field count")
|
||||
errBadVersion = errors.New("unsupported stamp version")
|
||||
errInsufficientBits = errors.New("insufficient difficulty")
|
||||
errWrongResource = errors.New("wrong resource")
|
||||
errStampExpired = errors.New("stamp expired")
|
||||
errStampFuture = errors.New("stamp date in future")
|
||||
errProofFailed = errors.New("proof-of-work failed")
|
||||
errStampReused = errors.New("stamp already used")
|
||||
errBadDateFormat = errors.New("unrecognized date format")
|
||||
)
|
||||
|
||||
// Validator checks hashcash stamps for validity and
|
||||
// prevents replay attacks via an in-memory spent set.
|
||||
type Validator struct {
|
||||
resource string
|
||||
mu sync.Mutex
|
||||
spent map[string]time.Time
|
||||
}
|
||||
|
||||
// NewValidator creates a Validator for the given resource.
|
||||
func NewValidator(resource string) *Validator {
|
||||
validator := &Validator{
|
||||
resource: resource,
|
||||
mu: sync.Mutex{},
|
||||
spent: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
go validator.pruneLoop()
|
||||
|
||||
return validator
|
||||
}
|
||||
|
||||
// Validate checks a hashcash stamp. It returns nil if the
|
||||
// stamp is valid and has not been seen before.
|
||||
func (v *Validator) Validate(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
) 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]
|
||||
|
||||
if err := v.validateHeader(
|
||||
version, bitsStr, resource, requiredBits,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stampTime, err := parseStampDate(dateStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateTime(stampTime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProof(
|
||||
stamp, requiredBits,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.checkAndRecordStamp(stamp, stampTime)
|
||||
}
|
||||
|
||||
func (v *Validator) validateHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
) 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 != v.resource {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, v.resource,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTime(stampTime time.Time) error {
|
||||
now := time.Now()
|
||||
|
||||
if now.Sub(stampTime) > maxStampAge {
|
||||
return errStampExpired
|
||||
}
|
||||
|
||||
if stampTime.Sub(now) > maxFutureSkew {
|
||||
return errStampFuture
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProof(stamp string, requiredBits int) error {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d leading zero bits",
|
||||
errProofFailed, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Validator) checkAndRecordStamp(
|
||||
stamp string,
|
||||
stampTime time.Time,
|
||||
) error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if _, ok := v.spent[stamp]; ok {
|
||||
return errStampReused
|
||||
}
|
||||
|
||||
v.spent[stamp] = stampTime
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if the hash has at least n
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
||||
fullBytes := numBits / bitsPerByte
|
||||
remainBits := numBits % bitsPerByte
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(
|
||||
fullByteMask << (bitsPerByte - remainBits),
|
||||
)
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// parseStampDate parses a hashcash date stamp.
|
||||
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
||||
func parseStampDate(dateStr string) (time.Time, error) {
|
||||
switch len(dateStr) {
|
||||
case dateShortLen:
|
||||
parsed, err := time.Parse(
|
||||
dateFormatShort, dateStr,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"parse date: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
case dateLongLen:
|
||||
parsed, err := time.Parse(
|
||||
dateFormatLong, dateStr,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"parse date: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"%w: %q", errBadDateFormat, dateStr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneLoop periodically removes expired stamps from the
|
||||
// spent set.
|
||||
func (v *Validator) pruneLoop() {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
v.prune()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) prune() {
|
||||
cutoff := time.Now().Add(-maxStampAge)
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
for stamp, stampTime := range v.spent {
|
||||
if stampTime.Before(cutoff) {
|
||||
delete(v.spent, stamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
package hashcash_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const testBits = 2
|
||||
|
||||
// mintStampWithDate creates a valid hashcash stamp using
|
||||
// the given date string.
|
||||
func mintStampWithDate(
|
||||
tb testing.TB,
|
||||
bits int,
|
||||
resource string,
|
||||
date string,
|
||||
) string {
|
||||
tb.Helper()
|
||||
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s::", bits, date, resource,
|
||||
)
|
||||
|
||||
for {
|
||||
counterVal, err := rand.Int(
|
||||
rand.Reader, big.NewInt(1<<48),
|
||||
)
|
||||
if err != nil {
|
||||
tb.Fatalf("random counter: %v", err)
|
||||
}
|
||||
|
||||
stamp := prefix + hex.EncodeToString(
|
||||
counterVal.Bytes(),
|
||||
)
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if hash has at least numBits
|
||||
// leading zero bits. Duplicated here for test minting.
|
||||
func hasLeadingZeroBits(
|
||||
hash []byte,
|
||||
numBits int,
|
||||
) bool {
|
||||
fullBytes := numBits / 8
|
||||
remainBits := numBits % 8
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(0xFF << (8 - remainBits))
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func todayDate() string {
|
||||
return time.Now().UTC().Format("060102")
|
||||
}
|
||||
|
||||
func TestMintAndValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("valid stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("first use failed: %v", err)
|
||||
}
|
||||
|
||||
err = validator.Validate(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("replay not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("correct-resource")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "wrong-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected resource mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidStampFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
|
||||
err := validator.Validate(
|
||||
"not:a:valid:stamp", testBits,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := fmt.Sprintf(
|
||||
"2:%d:%s:%s::abc123",
|
||||
testBits, todayDate(), "test-resource",
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsufficientDifficulty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
// Claimed bits=1, but we require testBits=2.
|
||||
stamp := fmt.Sprintf(
|
||||
"1:1:%s:%s::counter",
|
||||
todayDate(), "test-resource",
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient bits error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
oldDate := time.Now().Add(-72 * time.Hour).
|
||||
UTC().Format("060102")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", oldDate,
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired stamp error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroBitsSkipsValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
|
||||
err := validator.Validate("garbage", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongDateFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
longDate := time.Now().UTC().Format("060102150405")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", longDate,
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("long date stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadDateFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := fmt.Sprintf(
|
||||
"1:%d:BADDATE:%s::counter",
|
||||
testBits, "test-resource",
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad date error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleUniqueStamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
|
||||
for range 5 {
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("unique stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHigherBitsStillValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mint with bits=4 but validate requiring only 2.
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := mintStampWithDate(
|
||||
t, 4, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"higher-difficulty stamp rejected: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -22,7 +21,6 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *db.Database
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
// Healthcheck tracks server uptime and provides health status.
|
||||
@@ -66,22 +64,11 @@ type Response struct {
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenanceMode"`
|
||||
|
||||
// Runtime statistics.
|
||||
Sessions int64 `json:"sessions"`
|
||||
Clients int64 `json:"clients"`
|
||||
QueuedLines int64 `json:"queuedLines"`
|
||||
Channels int64 `json:"channels"`
|
||||
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
|
||||
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
|
||||
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
|
||||
}
|
||||
|
||||
// Healthcheck returns the current health status of the server.
|
||||
func (hcheck *Healthcheck) Healthcheck(
|
||||
ctx context.Context,
|
||||
) *Response {
|
||||
resp := &Response{
|
||||
func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||
return &Response{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
|
||||
Appname: hcheck.params.Globals.Appname,
|
||||
Version: hcheck.params.Globals.Version,
|
||||
Maintenance: hcheck.params.Config.MaintenanceMode,
|
||||
|
||||
Sessions: 0,
|
||||
Clients: 0,
|
||||
QueuedLines: 0,
|
||||
Channels: 0,
|
||||
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
|
||||
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
|
||||
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
|
||||
}
|
||||
|
||||
hcheck.populateDBStats(ctx, resp)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// populateDBStats fills in database-derived counters.
|
||||
func (hcheck *Healthcheck) populateDBStats(
|
||||
ctx context.Context,
|
||||
resp *Response,
|
||||
) {
|
||||
sessions, err := hcheck.params.Database.GetUserCount(ctx)
|
||||
if err != nil {
|
||||
hcheck.log.Error(
|
||||
"healthcheck: session count failed",
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
resp.Sessions = sessions
|
||||
}
|
||||
|
||||
clients, err := hcheck.params.Database.GetClientCount(ctx)
|
||||
if err != nil {
|
||||
hcheck.log.Error(
|
||||
"healthcheck: client count failed",
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
resp.Clients = clients
|
||||
}
|
||||
|
||||
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
|
||||
if err != nil {
|
||||
hcheck.log.Error(
|
||||
"healthcheck: queue entry count failed",
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
resp.QueuedLines = queued
|
||||
}
|
||||
|
||||
channels, err := hcheck.params.Database.GetChannelCount(ctx)
|
||||
if err != nil {
|
||||
hcheck.log.Error(
|
||||
"healthcheck: channel count failed",
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
resp.Channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package irc
|
||||
|
||||
// IRC command names (RFC 1459 / RFC 2812).
|
||||
const (
|
||||
CmdAway = "AWAY"
|
||||
CmdJoin = "JOIN"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
150
internal/irc/numerics.go
Normal file
150
internal/irc/numerics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// 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]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Package stats tracks runtime statistics since server boot.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Tracker holds atomic counters for runtime statistics
|
||||
// that accumulate since the server started.
|
||||
type Tracker struct {
|
||||
connectionsSinceBoot atomic.Int64
|
||||
sessionsSinceBoot atomic.Int64
|
||||
messagesSinceBoot atomic.Int64
|
||||
}
|
||||
|
||||
// New creates a new Tracker with all counters at zero.
|
||||
func New() *Tracker {
|
||||
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
|
||||
}
|
||||
|
||||
// IncrConnections increments the total connection count.
|
||||
func (t *Tracker) IncrConnections() {
|
||||
t.connectionsSinceBoot.Add(1)
|
||||
}
|
||||
|
||||
// IncrSessions increments the total session count.
|
||||
func (t *Tracker) IncrSessions() {
|
||||
t.sessionsSinceBoot.Add(1)
|
||||
}
|
||||
|
||||
// IncrMessages increments the total PRIVMSG/NOTICE count.
|
||||
func (t *Tracker) IncrMessages() {
|
||||
t.messagesSinceBoot.Add(1)
|
||||
}
|
||||
|
||||
// ConnectionsSinceBoot returns the total number of
|
||||
// client connections since boot.
|
||||
func (t *Tracker) ConnectionsSinceBoot() int64 {
|
||||
return t.connectionsSinceBoot.Load()
|
||||
}
|
||||
|
||||
// SessionsSinceBoot returns the total number of sessions
|
||||
// created since boot.
|
||||
func (t *Tracker) SessionsSinceBoot() int64 {
|
||||
return t.sessionsSinceBoot.Load()
|
||||
}
|
||||
|
||||
// MessagesSinceBoot returns the total number of
|
||||
// PRIVMSG/NOTICE messages sent since boot.
|
||||
func (t *Tracker) MessagesSinceBoot() int64 {
|
||||
return t.messagesSinceBoot.Load()
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package stats_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
if tracker == nil {
|
||||
t.Fatal("expected non-nil tracker")
|
||||
}
|
||||
|
||||
if tracker.ConnectionsSinceBoot() != 0 {
|
||||
t.Errorf(
|
||||
"expected 0 connections, got %d",
|
||||
tracker.ConnectionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.SessionsSinceBoot() != 0 {
|
||||
t.Errorf(
|
||||
"expected 0 sessions, got %d",
|
||||
tracker.SessionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.MessagesSinceBoot() != 0 {
|
||||
t.Errorf(
|
||||
"expected 0 messages, got %d",
|
||||
tracker.MessagesSinceBoot(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrConnections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrConnections()
|
||||
tracker.IncrConnections()
|
||||
tracker.IncrConnections()
|
||||
|
||||
got := tracker.ConnectionsSinceBoot()
|
||||
if got != 3 {
|
||||
t.Errorf(
|
||||
"expected 3 connections, got %d", got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrSessions()
|
||||
tracker.IncrSessions()
|
||||
|
||||
got := tracker.SessionsSinceBoot()
|
||||
if got != 2 {
|
||||
t.Errorf(
|
||||
"expected 2 sessions, got %d", got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrMessages()
|
||||
|
||||
got := tracker.MessagesSinceBoot()
|
||||
if got != 1 {
|
||||
t.Errorf(
|
||||
"expected 1 message, got %d", got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountersAreIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrConnections()
|
||||
tracker.IncrSessions()
|
||||
tracker.IncrMessages()
|
||||
tracker.IncrMessages()
|
||||
|
||||
if tracker.ConnectionsSinceBoot() != 1 {
|
||||
t.Errorf(
|
||||
"expected 1 connection, got %d",
|
||||
tracker.ConnectionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.SessionsSinceBoot() != 1 {
|
||||
t.Errorf(
|
||||
"expected 1 session, got %d",
|
||||
tracker.SessionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.MessagesSinceBoot() != 2 {
|
||||
t.Errorf(
|
||||
"expected 2 messages, got %d",
|
||||
tracker.MessagesSinceBoot(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,391 +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
|
||||
|
||||
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]
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -8,56 +8,6 @@ const MEMBER_REFRESH_INTERVAL = 10000;
|
||||
const ACTION_PREFIX = "\x01ACTION ";
|
||||
const ACTION_SUFFIX = "\x01";
|
||||
|
||||
// Hashcash proof-of-work helpers using Web Crypto API.
|
||||
|
||||
function checkLeadingZeros(hashBytes, bits) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < hashBytes.length; i++) {
|
||||
if (hashBytes[i] === 0) {
|
||||
count += 8;
|
||||
continue;
|
||||
}
|
||||
let b = hashBytes[i];
|
||||
while ((b & 0x80) === 0) {
|
||||
count++;
|
||||
b <<= 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return count >= bits;
|
||||
}
|
||||
|
||||
async function mintHashcash(bits, resource) {
|
||||
const encoder = new TextEncoder();
|
||||
const now = new Date();
|
||||
const date =
|
||||
String(now.getUTCFullYear()).slice(2) +
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0") +
|
||||
String(now.getUTCDate()).padStart(2, "0");
|
||||
const prefix = `1:${bits}:${date}:${resource}::`;
|
||||
let nonce = Math.floor(Math.random() * 0x100000);
|
||||
const batchSize = 1024;
|
||||
|
||||
for (;;) {
|
||||
const stamps = [];
|
||||
const hashPromises = [];
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const stamp = prefix + (nonce + i).toString(16);
|
||||
stamps.push(stamp);
|
||||
hashPromises.push(
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
|
||||
);
|
||||
}
|
||||
const hashes = await Promise.all(hashPromises);
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
|
||||
return stamps[i];
|
||||
}
|
||||
}
|
||||
nonce += batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
function api(path, opts = {}) {
|
||||
const token = localStorage.getItem("neoirc_token");
|
||||
const headers = {
|
||||
@@ -110,16 +60,12 @@ function LoginScreen({ onLogin }) {
|
||||
const [motd, setMotd] = useState("");
|
||||
const [serverName, setServerName] = useState("NeoIRC");
|
||||
const inputRef = useRef();
|
||||
const hashcashBitsRef = useRef(0);
|
||||
const hashcashResourceRef = useRef("neoirc");
|
||||
|
||||
useEffect(() => {
|
||||
api("/server")
|
||||
.then((s) => {
|
||||
if (s.name) setServerName(s.name);
|
||||
if (s.motd) setMotd(s.motd);
|
||||
hashcashBitsRef.current = s.hashcash_bits || 0;
|
||||
if (s.name) hashcashResourceRef.current = s.name;
|
||||
})
|
||||
.catch(() => {});
|
||||
const saved = localStorage.getItem("neoirc_token");
|
||||
@@ -135,22 +81,9 @@ function LoginScreen({ onLogin }) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
let hashcashStamp = "";
|
||||
if (hashcashBitsRef.current > 0) {
|
||||
setError("Computing proof-of-work...");
|
||||
hashcashStamp = await mintHashcash(
|
||||
hashcashBitsRef.current,
|
||||
hashcashResourceRef.current,
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
const reqBody = { nick: nick.trim() };
|
||||
if (hashcashStamp) {
|
||||
reqBody.pow_token = hashcashStamp;
|
||||
}
|
||||
const res = await api("/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(reqBody),
|
||||
body: JSON.stringify({ nick: nick.trim() }),
|
||||
});
|
||||
localStorage.setItem("neoirc_token", res.token);
|
||||
onLogin(res.nick);
|
||||
|
||||
Reference in New Issue
Block a user