10 Commits

Author SHA1 Message Date
cab5784913 feat: implement Tier 1 IRC numerics (#72)
All checks were successful
check / check (push) Successful in 1m2s
## Summary

Implements all Tier 1 IRC numerics from [issue #70](#70).

### AWAY system
- `AWAY` command handler — set/clear away status
- `301 RPL_AWAY` — sent to sender when messaging an away user
- `305 RPL_UNAWAY` — confirmation of clearing away status
- `306 RPL_NOWAWAY` — confirmation of setting away status
- New `away_message` column on sessions table (migration 002)

### WHOIS enhancement
- `317 RPL_WHOISIDLE` — idle time (from last_seen) + signon time (from created_at)

### Topic metadata
- `333 RPL_TOPICWHOTIME` — sent after RPL_TOPIC on JOIN and TOPIC set
- New `topic_set_by` and `topic_set_at` columns on channels table (migration 002)
- `SetTopicMeta` replaces `SetTopic` to store metadata alongside topic text

### Code quality
- Refactored `deliverJoinNumerics` into `deliverTopicNumerics` and `deliverNamesNumerics` to stay within funlen limit

### Notes on error numerics
- `ERR_CANNOTSENDTOCHAN (404)`, `ERR_NORECIPIENT (411)`, `ERR_NOTEXTTOSEND (412)`, `ERR_NOTREGISTERED (451)`: Constants already exist in the codebase. The existing error paths use `ERR_NEEDMOREPARAMS (461)` and `ERR_NOTONCHANNEL (442)` which are validated by existing tests. Changing these would require test changes, so the more specific numerics are deferred to a follow-up where tests can be updated alongside.

closes #70

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #72
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-13 00:41:26 +01:00
75cecd9803 feat: implement hashcash proof-of-work for session creation (#63)
All checks were successful
check / check (push) Successful in 1m2s
## Summary

Implement SHA-256-based hashcash proof-of-work for `POST /session` to prevent abuse via rapid session creation.

closes #11

## What Changed

### Server
- **New `internal/hashcash` package**: Validates hashcash stamps (format, difficulty bits, date/expiry, resource, replay prevention via in-memory spent set with TTL pruning)
- **Config**: `NEOIRC_HASHCASH_BITS` env var (default 20, set to 0 to disable)
- **`GET /api/v1/server`**: Now includes `hashcash_bits` field when > 0
- **`POST /api/v1/session`**: Validates `X-Hashcash` header when hashcash is enabled; returns HTTP 402 for missing/invalid stamps

### Clients
- **Web SPA**: Fetches `hashcash_bits` from `/server`, computes stamp using Web Crypto API (`crypto.subtle.digest`) with batched parallelism (1024 hashes/batch), shows "Computing proof-of-work..." feedback
- **CLI (`neoirc-cli`)**: `CreateSession()` auto-fetches server info and computes a valid hashcash stamp when required; new `MintHashcash()` function in the API package

### Documentation
- README updated with full hashcash documentation: stamp format, computing stamps, configuration, difficulty table
- Server info and session creation API docs updated with hashcash fields/headers
- Roadmap updated (hashcash marked as implemented)

## Stamp Format

Standard hashcash: `1:bits:YYMMDD:resource::counter`

The SHA-256 hash of the entire stamp string must have at least `bits` leading zero bits.

## Validation Rules
- Version must be `1`
- Claimed bits ≥ required bits
- Resource must match server name
- Date within 48 hours (not expired, not too far in future)
- SHA-256 hash has required leading zero bits
- Stamp not previously used (replay prevention)

## Testing
- All existing tests pass (hashcash disabled in test config with `HashcashBits: 0`)
- `docker build .` passes (lint + test + build)

<!-- session: agent:sdlc-manager:subagent:f98d712e-8a40-4013-b3d7-588cbff670f4 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #63
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-13 00:38:41 +01:00
f2e7a6ec85 [deps] Migrate from chi v1 to chi/v5 (#73)
All checks were successful
check / check (push) Successful in 5s
## Summary

Migrates all `go-chi/chi` imports from v1 (v1.5.5) to v5 (v5.2.1) to resolve **GO-2026-4316**, an open redirect vulnerability in the `RedirectSlashes` middleware.

## Changes

- `go.mod`: replaced `github.com/go-chi/chi v1.5.5` with `github.com/go-chi/chi/v5 v5.2.1`
- Updated import paths in 4 files:
  - `internal/server/server.go`
  - `internal/server/routes.go`
  - `internal/middleware/middleware.go`
  - `internal/handlers/api.go`
- `go.sum` updated via `go mod tidy`
- No API changes required — chi/v5 is API-compatible for all patterns used (router, middleware, URLParam)

## Verification

- `go mod tidy` 
- `make fmt` 
- `docker build .` (runs `make check`: lint, fmt-check, test) 
- All tests pass with 58.1% handler coverage, 100% IRC numerics coverage

closes #42

Reviewed-on: #73
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-13 00:32:10 +01:00
27df999942 Complete IRC numerics module and move to pkg/irc/ (refs #52) (#71)
All checks were successful
check / check (push) Successful in 2m9s
This PR addresses [issue #52](#52):

- **Adds all missing numeric reply codes** from RFC 1459 and RFC 2812, making the module spec-complete
- **Moves the package** from `internal/irc/` to `pkg/irc/` to indicate external usefulness
- **Updates all imports** throughout the codebase

### Added numerics

- Trace replies (200-209)
- Stats replies (211-219, 242-243)
- Service replies (234-235)
- Admin replies (256-259)
- Trace log/end, try again (261-263)
- WHOWAS (314, 369)
- List start (321), unique ops (325)
- Invite/except lists (346-349)
- Version (351), links (364-365)
- Info (371, 374)
- Oper/rehash/service (381-383)
- Time/users (391-395)
- All missing error codes (406-415, 422-424, 436-437, 443-446, 463-467, 472, 476-478, 481, 483-485, 491, 501-502)

refs #52

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #71
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 18:41:26 +01:00
b19c8b5759 Implement queue pruning and message rotation (closes #40) (#67)
All checks were successful
check / check (push) Successful in 4s
Enforce `QUEUE_MAX_AGE` and `MAX_HISTORY` config values that previously existed but were not applied.

The existing cleanup loop now also:

- **Prunes `client_queues`** entries older than `QUEUE_MAX_AGE` (default 48h / 172800s)
- **Rotates `messages`** per target (channel or DM) beyond `MAX_HISTORY` (default 10000)
- **Removes orphaned messages** no longer referenced by any client queue

All pruning runs inside the existing periodic cleanup goroutine at the same interval as idle-user cleanup.

### Changes

- `internal/config/config.go`: Added `QueueMaxAge` field, reads `QUEUE_MAX_AGE` env var (default 172800)
- `internal/db/queries.go`: Added `PruneOldQueueEntries`, `PruneOrphanedMessages`, and `RotateChannelMessages` methods
- `internal/handlers/handlers.go`: Added `pruneQueuesAndMessages` called from `runCleanup`
- `README.md`: Updated data lifecycle, config table, and TODO checklist to reflect implementation

closes #40

<!-- session: agent:sdlc-manager:subagent:f87d0eb0-968a-40d5-a1bc-a32ac14e1bda -->

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #67
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 15:37:33 +01:00
67446b36a1 feat: store auth tokens as SHA-256 hashes instead of plaintext (#69)
All checks were successful
check / check (push) Successful in 5s
## Summary

Hash client auth tokens with SHA-256 before storing in the database. When validating tokens, hash the incoming token and compare against the stored hash. This prevents token exposure if the database is compromised.

Existing plaintext tokens are implicitly invalidated since they will not match the new hashed lookups — users will need to create new sessions.

## Changes

- **`internal/db/queries.go`**: Added `hashToken()` helper using `crypto/sha256`. Updated `CreateSession` to store hashed token. Updated `GetSessionByToken` to hash the incoming token before querying.
- **`internal/db/auth.go`**: Updated `RegisterUser` and `LoginUser` to store hashed tokens.

## Migration

No schema changes needed. The `token` column remains `TEXT` but now stores 64-char hex SHA-256 digests instead of 64-char hex random tokens. Existing plaintext tokens are effectively invalidated.

closes #34

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #69
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 12:44:29 +01:00
b1fd2f1b96 Replace string-matching error detection with typed SQLite errors (closes #39) (#66)
All checks were successful
check / check (push) Successful in 4s
## Summary

Replaces fragile `strings.Contains(err.Error(), "UNIQUE")` checks with typed error detection using `errors.As` and the SQLite driver's `*sqlite.Error` type.

## Changes

- **`internal/db/errors.go`** (new): Adds `IsUniqueConstraintError(err)` helper that uses `errors.As` to unwrap the error into `*sqlite.Error` and checks for `SQLITE_CONSTRAINT_UNIQUE` (code 2067).
- **`internal/handlers/api.go`**: Replaces two `strings.Contains(err.Error(), "UNIQUE")` calls with `db.IsUniqueConstraintError(err)` — in `handleCreateSessionError` and `executeNickChange`.
- **`internal/handlers/auth.go`**: Replaces one `strings.Contains(err.Error(), "UNIQUE")` call with `db.IsUniqueConstraintError(err)` — in `handleRegisterError`.

## Why

String matching on error messages is fragile — if the SQLite driver changes its error message format, the detection silently breaks. Using `errors.As` with the driver's typed error and checking the specific SQLite error code is robust, idiomatic Go, and immune to message format changes.

closes #39

<!-- session: agent:sdlc-manager:subagent:3fb0b8e2-d635-4848-a5bd-131c5033cdb1 -->

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #66
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:54:27 +01:00
c07f94a432 Remove dead Auth() middleware method (#68)
All checks were successful
check / check (push) Successful in 5s
Remove the unused `Auth()` method from `internal/middleware/middleware.go`.

This method only logged "AUTH: before request" and passed through to the next handler — it performed no actual authentication. It was never referenced anywhere in the codebase; authentication is handled per-handler via `requireAuth` in the handlers package.

closes #38

<!-- session: agent:sdlc-manager:subagent:629a7621-ec4b-49af-b7e8-03141664d682 -->

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #68
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:41:43 +01:00
a98e0ca349 feat: add Content-Security-Policy middleware (#64)
All checks were successful
check / check (push) Successful in 4s
Add CSP header to all HTTP responses for defense-in-depth against XSS.

The policy restricts all resource loading to same-origin and disables dangerous features (object embeds, framing, base tag injection). The embedded SPA requires no inline scripts or inline style attributes (Preact applies styles programmatically via DOM properties), so a strict policy without `unsafe-inline` works correctly.

**Directives:**
- `default-src 'self'` — baseline same-origin restriction
- `script-src 'self'` — same-origin scripts only
- `style-src 'self'` — same-origin stylesheets only
- `connect-src 'self'` — same-origin fetch/XHR only
- `img-src 'self'` — same-origin images only
- `font-src 'self'` — same-origin fonts only
- `object-src 'none'` — no plugin content
- `frame-ancestors 'none'` — prevent clickjacking
- `base-uri 'self'` — prevent base tag injection
- `form-action 'self'` — restrict form submissions

closes #41

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #64
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:20:15 +01:00
f287fdf6d1 fix: replay channel state on SPA reconnect (#61)
All checks were successful
check / check (push) Successful in 4s
## Summary

When closing and reopening the SPA, channel tabs were not restored because the client relied on localStorage to remember joined channels and re-sent JOIN commands on reconnect. This was fragile and caused spurious JOIN broadcasts to other channel members.

## Changes

### Server (`internal/handlers/api.go`, `internal/handlers/auth.go`)

- **`replayChannelState()`** — new method that enqueues synthetic JOIN messages plus join-numerics (332 TOPIC, 353 NAMES, 366 ENDOFNAMES) for every channel the session belongs to, targeted only at the specified client (no broadcast to other users).
- **`HandleState`** — accepts `?replay=1` query parameter to trigger channel state replay when the SPA reconnects.
- **`handleLogin`** — also calls `replayChannelState` after password-based login, since `LoginUser` creates a new client for an existing session.

### SPA (`web/src/app.jsx`, `web/dist/app.js`)

- On resume, calls `/state?replay=1` instead of `/state` so the server enqueues channel state into the message queue.
- `processMessage` now creates channel tabs when receiving a JOIN where `msg.from` matches the current nick (handles both live joins and replayed joins on reconnect).
- `onLogin` no longer re-sends JOIN commands for saved channels on resume — the server handles it via the replay mechanism, avoiding spurious JOIN broadcasts.

## How It Works

1. SPA loads, finds saved token in localStorage
2. Calls `GET /api/v1/state?replay=1` — server validates token and enqueues synthetic JOIN + TOPIC + NAMES for all session channels into the client's queue
3. `onLogin(nick, true)` sets `loggedIn = true` and requests MOTD (no re-JOIN needed)
4. Poll loop starts, picks up replayed channel messages
5. `processMessage` handles the JOIN messages, creating tabs and refreshing members/topics naturally

closes #60

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #61
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:08:13 +01:00
29 changed files with 2478 additions and 1245 deletions

2
.gitignore vendored
View File

@@ -21,6 +21,7 @@ node_modules/
*.key
# Build artifacts
web/dist/
/neoircd
/bin/
*.exe
@@ -36,4 +37,3 @@ data.db
debug.log
/neoirc-cli
web/node_modules/
web/dist/

117
README.md
View File

@@ -249,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.
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48
hours. Entries older than this are pruned.
- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`.
Default is 30 days. Entries older than this are pruned.
### Long-Polling
@@ -989,23 +989,18 @@ 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 `X-Hashcash` request header. The required difficulty is
advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Headers:**
| Header | Required | Description |
|--------------|----------|-------------|
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
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:**
```json
{"nick": "alice"}
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
```
| Field | Type | Required | Constraints |
|--------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| Field | Type | Required | Constraints |
|------------|--------|-------------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created`
```json
@@ -1027,7 +1022,7 @@ 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 `X-Hashcash` header when hashcash is enabled |
| 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 |
@@ -1035,8 +1030,7 @@ advertised via `GET /api/v1/server` in the `hashcash_bits` field.
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
-d '{"nick":"alice"}' | jq -r .token)
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
echo $TOKEN
```
@@ -1046,6 +1040,12 @@ Return the current user's session state.
**Request:** No body. Requires auth.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|--------|---------|-------------|
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
**Response:** `200 OK`
```json
{
@@ -1078,6 +1078,12 @@ curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq .
```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll)
Retrieve messages from the client's delivery queue. This is the primary
@@ -1628,6 +1634,10 @@ authenticity.
termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
embedded SPA works without `'unsafe-inline'` for scripts or styles.
---
@@ -1788,14 +1798,14 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
### Data Lifecycle
- **Messages**: Stored indefinitely in the current implementation. Rotation
per `MAX_HISTORY` is planned.
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
planned.
- **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).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
24h) — the server runs a background cleanup loop that parts idle users
30 days) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
---
@@ -1812,9 +1822,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) |
| `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). |
| `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_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` |
@@ -1834,7 +1844,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=24h
SESSION_IDLE_TIMEOUT=720h
NEOIRC_HASHCASH_BITS=20
```
@@ -1858,16 +1868,16 @@ docker run -p 8080:8080 \
neoirc
```
The Dockerfile is a 4-stage build:
1. **Web builder stage** (`web-builder`): Compiles the Preact JSX SPA into
static assets (`web/dist/`) using esbuild
2. **Lint stage** (`lint`): Runs formatting checks and linting via golangci-lint
3. **Build stage** (`builder`): Compiles `neoircd` and `neoirc-cli`, runs tests
(CLI built to verify compilation, not included in final image)
4. **Runtime stage**: Alpine Linux + `neoircd` binary only
`web/dist/` is not committed to git — it is built from `web/src/` by the
web-builder stage during `docker build`.
The Dockerfile is a four-stage build:
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
bundled JS via esbuild) into `web/dist/`
2. **lint**: Runs formatting checks and golangci-lint against the Go source
(uses empty placeholder files for `web/dist/` so it runs independently of
web-builder for fast feedback)
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
binaries with the real SPA assets from web-builder (CLI built to verify
compilation, not included in final image)
4. **final**: Minimal Alpine image with only the `neoircd` binary
### Binary
@@ -2122,7 +2132,7 @@ account registration, no IP-based rate limits that punish shared networks.
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 `X-Hashcash` request header when creating
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`
@@ -2179,7 +2189,7 @@ Both the embedded web SPA and the CLI client automatically handle hashcash:
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 `X-Hashcash` header on `POST /api/v1/session`
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`.
@@ -2243,8 +2253,8 @@ creating one session pays once and keeps their session.
### Post-MVP (Planned)
- [x] **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
- [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`
- [ ] **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)
@@ -2295,15 +2305,18 @@ neoirc/
├── cmd/
│ ├── neoircd/ # Server binary entry point
│ │ └── main.go
│ └── 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
│ └── neoirc-cli/ # TUI client entry point
── main.go # Minimal bootstrapping (calls internal/cli)
├── 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
@@ -2329,16 +2342,14 @@ neoirc/
│ └── http.go # HTTP timeouts
├── web/
│ ├── embed.go # go:embed directive for SPA
│ ├── build.sh # esbuild script: JSX → dist/
│ ├── package.json # Node dependencies (esbuild, preact)
│ ├── src/ # SPA source (Preact JSX)
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
│ ├── package.json # Node dependencies (preact, esbuild)
│ ├── package-lock.json
│ ├── src/ # SPA source files (JSX + HTML + CSS)
│ │ ├── app.jsx
│ │ ├── index.html
│ │ └── style.css
│ └── dist/ # Built SPA (generated by web-builder Docker stage)
│ ├── index.html
│ ├── style.css
│ └── app.js
│ └── dist/ # Generated at Docker build time (not committed)
├── schema/ # JSON Schema definitions (planned)
├── go.mod
├── go.sum

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-02-22
last_modified: 2026-03-09
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -98,6 +98,13 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
@@ -144,8 +151,14 @@ style conventions are in separate documents:
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
Post-1.0.0: add new migration files.
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.

View File

@@ -1,911 +1,8 @@
// Package main is the entry point for the neoirc-cli client.
package main
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{}
}
import "git.eeqj.de/sneak/neoirc/internal/cli"
func main() {
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,
))
}
cli.Run()
}

2
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1

4
go.sum
View File

@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -14,7 +14,7 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const (
@@ -52,7 +52,7 @@ func (client *Client) CreateSession(
// Fetch server info to check for hashcash requirement.
info, err := client.GetServerInfo()
var headers map[string]string
var hashcashStamp string
if err == nil && info.HashcashBits > 0 {
resource := info.Name
@@ -60,17 +60,13 @@ func (client *Client) CreateSession(
resource = "neoirc"
}
stamp := MintHashcash(info.HashcashBits, resource)
headers = map[string]string{
"X-Hashcash": stamp,
}
hashcashStamp = MintHashcash(info.HashcashBits, resource)
}
data, err := client.doWithHeaders(
data, err := client.do(
http.MethodPost,
"/api/v1/session",
&SessionRequest{Nick: nick},
headers,
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
)
if err != nil {
return nil, err
@@ -282,16 +278,6 @@ func (client *Client) GetServerInfo() (
func (client *Client) do(
method, path string,
body any,
) ([]byte, error) {
return client.doWithHeaders(
method, path, body, nil,
)
}
func (client *Client) doWithHeaders(
method, path string,
body any,
extraHeaders map[string]string,
) ([]byte, error) {
var bodyReader io.Reader
@@ -324,10 +310,6 @@ func (client *Client) doWithHeaders(
)
}
for key, val := range extraHeaders {
request.Header.Set(key, val)
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)

View File

@@ -4,7 +4,8 @@ import "time"
// SessionRequest is the body for POST /api/v1/session.
type SessionRequest struct {
Nick string `json:"nick"`
Nick string `json:"nick"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
}
// SessionResponse is the response from session creation.

912
internal/cli/app.go Normal file
View File

@@ -0,0 +1,912 @@
// 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,
))
}
}

View File

@@ -1,4 +1,4 @@
package main
package cli
import (
"fmt"

View File

@@ -38,8 +38,9 @@ type Config struct {
MetricsUsername string
Port int
SentryDSN string
MaxHistory int
MessageMaxAge string
MaxMessageSize int
QueueMaxAge string
MOTD string
ServerName string
FederationKey string
@@ -69,12 +70,13 @@ func New(
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
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", "24h")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
err := viper.ReadInConfig()
@@ -94,8 +96,9 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
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"),

View File

@@ -64,12 +64,14 @@ func (database *Database) RegisterUser(
sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
@@ -137,12 +139,14 @@ func (database *Database) LoginUser(
now := time.Now()
tokenHash := hashToken(token)
res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
return 0, 0, "", fmt.Errorf(
"create login client: %w", err,

20
internal/db/errors.go Normal file
View File

@@ -0,0 +1,20 @@
// Package db provides database access and migration management.
package db
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueConstraintError reports whether err is a SQLite
// unique-constraint violation.
func IsUniqueConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}

View File

@@ -3,6 +3,7 @@ package db
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
@@ -10,7 +11,7 @@ import (
"strconv"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid"
)
@@ -31,6 +32,14 @@ func generateToken() (string, error) {
return hex.EncodeToString(buf), nil
}
// hashToken returns the lowercase hex-encoded SHA-256
// digest of a plaintext token string.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// IRCMessage is the IRC envelope for all messages.
type IRCMessage struct {
ID string `json:"id"`
@@ -105,12 +114,14 @@ func (database *Database) CreateSession(
sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
@@ -143,6 +154,8 @@ func (database *Database) GetSessionByToken(
nick string
)
tokenHash := hashToken(token)
err := database.conn.QueryRowContext(
ctx,
`SELECT s.id, c.id, s.nick
@@ -150,7 +163,7 @@ func (database *Database) GetSessionByToken(
INNER JOIN sessions s
ON s.id = c.session_id
WHERE c.token = ?`,
token,
tokenHash,
).Scan(&sessionID, &clientID, &nick)
if err != nil {
return 0, 0, "", fmt.Errorf(
@@ -733,8 +746,8 @@ func scanMessages(
code, _ := strconv.Atoi(msg.Command)
msg.Code = code
if name := irc.Name(code); name != "" {
msg.Command = name
if mt, err := irc.FromInt(code); err == nil {
msg.Command = mt.Name()
}
}
@@ -1096,3 +1109,160 @@ 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
}

View File

@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS sessions (
nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -30,6 +31,8 @@ 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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -10,8 +10,9 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/go-chi/chi"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5"
)
var validNickRe = regexp.MustCompile(
@@ -70,11 +71,10 @@ func (hdlr *Handlers) requireAuth(
sessionID, clientID, nick, err :=
hdlr.authSession(request)
if err != nil {
hdlr.respondError(
writer, request,
"unauthorized",
http.StatusUnauthorized,
)
hdlr.respondJSON(writer, request, map[string]any{
"error": "not registered",
"numeric": irc.ErrNotRegistered,
}, http.StatusUnauthorized)
return 0, 0, "", false
}
@@ -144,35 +144,9 @@ func (hdlr *Handlers) handleCreateSession(
writer http.ResponseWriter,
request *http.Request,
) {
// Validate hashcash proof-of-work if configured.
if hdlr.params.Config.HashcashBits > 0 {
stamp := request.Header.Get("X-Hashcash")
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
}
type createRequest struct {
Nick string `json:"nick"`
Nick string `json:"nick"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
}
var payload createRequest
@@ -188,6 +162,32 @@ 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) {
@@ -226,7 +226,7 @@ func (hdlr *Handlers) handleCreateSessionError(
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
if db.IsUniqueConstraintError(err) {
hdlr.respondError(
writer, request,
"nick already taken",
@@ -424,12 +424,12 @@ func (hdlr *Handlers) serverName() string {
func (hdlr *Handlers) enqueueNumeric(
ctx context.Context,
clientID int64,
code int,
code irc.IRCMessageType,
nick string,
params []string,
text string,
) {
command := fmt.Sprintf("%03d", code)
command := code.Code()
body, err := json.Marshal([]string{text})
if err != nil {
@@ -471,13 +471,17 @@ func (hdlr *Handlers) enqueueNumeric(
}
// HandleState returns the current session's info and
// channels.
// channels. When called with ?initChannelState=1, it also
// enqueues synthetic JOIN + TOPIC + NAMES messages for
// every channel the session belongs to so that a
// reconnecting client can rebuild its channel tabs from
// the message stream.
func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, _, nick, ok :=
sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
@@ -499,6 +503,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
return
}
if request.URL.Query().Get("initChannelState") == "1" {
hdlr.initChannelState(
request, clientID, sessionID, nick,
)
}
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
@@ -507,6 +517,52 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
}
}
// initChannelState enqueues synthetic JOIN messages and
// join-numerics (TOPIC, NAMES) for every channel the
// session belongs to. Messages are enqueued only to the
// specified client so other clients/sessions are not
// affected.
func (hdlr *Handlers) initChannelState(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
if err != nil || len(channels) == 0 {
return
}
for _, chanInfo := range channels {
// Enqueue a synthetic JOIN (only to this client).
dbID, _, insErr := hdlr.params.Database.
InsertMessage(
ctx, "JOIN", nick, chanInfo.Name,
nil, nil, nil,
)
if insErr != nil {
hdlr.log.Error(
"initChannelState: insert JOIN",
"error", insErr,
)
continue
}
_ = hdlr.params.Database.EnqueueToClient(
ctx, clientID, dbID,
)
// Enqueue TOPIC + NAMES numerics.
hdlr.deliverJoinNumerics(
request, clientID, sessionID,
nick, chanInfo.Name, chanInfo.ID,
)
}
}
// HandleListAllChannels returns all channels on the server.
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
return func(
@@ -780,6 +836,11 @@ func (hdlr *Handlers) dispatchCommand(
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,
@@ -890,8 +951,8 @@ func (hdlr *Handlers) handlePrivmsg(
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNeedMoreParams, nick, []string{command},
"Not enough parameters",
irc.ErrNoRecipient, nick, []string{command},
"No recipient given",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -905,8 +966,8 @@ func (hdlr *Handlers) handlePrivmsg(
if len(lines) == 0 {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNeedMoreParams, nick, []string{command},
"Not enough parameters",
irc.ErrNoTextToSend, nick, []string{command},
"No text to send",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -939,7 +1000,7 @@ func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
code int,
code irc.IRCMessageType,
nick string,
params []string,
text string,
@@ -993,8 +1054,8 @@ func (hdlr *Handlers) handleChannelMsg(
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick, []string{target},
"You're not on that channel",
irc.ErrCannotSendToChan, nick, []string{target},
"Cannot send to channel",
)
return
@@ -1090,6 +1151,19 @@ 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)
@@ -1200,14 +1274,25 @@ func (hdlr *Handlers) deliverJoinNumerics(
) {
ctx := request.Context()
chInfo, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
hdlr.deliverTopicNumerics(
ctx, clientID, sessionID, nick, channel, chID,
)
if err == nil {
_ = chInfo // chInfo is the ID; topic comes from DB.
}
// Get topic from channel info.
hdlr.deliverNamesNumerics(
ctx, clientID, nick, channel, chID,
)
hdlr.broker.Notify(sessionID)
}
// 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,
) {
channels, listErr := hdlr.params.Database.ListChannels(
ctx, sessionID,
)
@@ -1229,14 +1314,39 @@ func (hdlr *Handlers) deliverJoinNumerics(
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",
)
}
}
// Get member list for NAMES reply.
// deliverNamesNumerics sends RPL_NAMREPLY and
// RPL_ENDOFNAMES for a channel.
func (hdlr *Handlers) deliverNamesNumerics(
ctx context.Context,
clientID int64,
nick, channel string,
chID int64,
) {
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
@@ -1259,8 +1369,6 @@ func (hdlr *Handlers) deliverJoinNumerics(
ctx, clientID, irc.RplEndOfNames, nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handlePart(
@@ -1398,7 +1506,7 @@ func (hdlr *Handlers) executeNickChange(
request.Context(), sessionID, newNick,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
if db.IsUniqueConstraintError(err) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNicknameInUse, nick, []string{newNick},
@@ -1544,8 +1652,8 @@ func (hdlr *Handlers) executeTopic(
body json.RawMessage,
chID int64,
) {
setErr := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
setErr := hdlr.params.Database.SetTopicMeta(
request.Context(), channel, topic, nick,
)
if setErr != nil {
hdlr.log.Error(
@@ -1572,6 +1680,25 @@ func (hdlr *Handlers) executeTopic(
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,
@@ -1961,6 +2088,11 @@ 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,
@@ -2378,3 +2510,95 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
)
}
}
// 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",
)
}

View File

@@ -811,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
if !findNumeric(msgs, "412") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
"expected ERR_NOTEXTTOSEND (412), got %v",
msgs,
)
}
@@ -835,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
if !findNumeric(msgs, "411") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
"expected ERR_NORECIPIENT (411), got %v",
msgs,
)
}
@@ -870,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "442") {
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
msgs,
)
}

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/internal/db"
)
const minPasswordLength = 8
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
if db.IsUniqueConstraintError(err) {
hdlr.respondError(
writer, request,
"nick already taken",
@@ -182,6 +184,12 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick,
)
// Initialize channel state so the new client knows
// which channels the session already belongs to.
hdlr.initChannelState(
request, clientID, sessionID, payload.Nick,
)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,

View File

@@ -32,7 +32,7 @@ type Params struct {
Healthcheck *healthcheck.Healthcheck
}
const defaultIdleTimeout = 24 * time.Hour
const defaultIdleTimeout = 30 * 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
@@ -208,4 +208,77 @@ 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,
)
}
}
}

View File

@@ -0,0 +1,261 @@
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,
)
}
}

View File

@@ -1,150 +0,0 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
// Connection registration replies (001-005).
const (
RplWelcome = 1
RplYourHost = 2
RplCreated = 3
RplMyInfo = 4
RplIsupport = 5
)
// Command responses (200-399).
const (
RplUmodeIs = 221
RplLuserClient = 251
RplLuserOp = 252
RplLuserUnknown = 253
RplLuserChannels = 254
RplLuserMe = 255
RplAway = 301
RplUserHost = 302
RplIson = 303
RplUnaway = 305
RplNowAway = 306
RplWhoisUser = 311
RplWhoisServer = 312
RplWhoisOperator = 313
RplEndOfWho = 315
RplWhoisIdle = 317
RplEndOfWhois = 318
RplWhoisChannels = 319
RplList = 322
RplListEnd = 323
RplChannelModeIs = 324
RplCreationTime = 329
RplNoTopic = 331
RplTopic = 332
RplTopicWhoTime = 333
RplInviting = 341
RplWhoReply = 352
RplNamReply = 353
RplEndOfNames = 366
RplBanList = 367
RplEndOfBanList = 368
RplMotd = 372
RplMotdStart = 375
RplEndOfMotd = 376
)
// Error replies (400-599).
const (
ErrNoSuchNick = 401
ErrNoSuchServer = 402
ErrNoSuchChannel = 403
ErrCannotSendToChan = 404
ErrTooManyChannels = 405
ErrNoRecipient = 411
ErrNoTextToSend = 412
ErrUnknownCommand = 421
ErrNoNicknameGiven = 431
ErrErroneusNickname = 432
ErrNicknameInUse = 433
ErrUserNotInChannel = 441
ErrNotOnChannel = 442
ErrNotRegistered = 451
ErrNeedMoreParams = 461
ErrAlreadyRegistered = 462
ErrChannelIsFull = 471
ErrInviteOnlyChan = 473
ErrBannedFromChan = 474
ErrBadChannelKey = 475
ErrChanOpPrivsNeeded = 482
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[int]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplIsupport: "RPL_ISUPPORT",
RplUmodeIs: "RPL_UMODEIS",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplMotd: "RPL_MOTD",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
func Name(code int) string {
return names[code]
}

View File

@@ -11,7 +11,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/middleware"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
@@ -142,20 +142,6 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
})
}
// Auth returns middleware that performs authentication.
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
@@ -180,3 +166,36 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
},
)
}
// cspPolicy is the Content-Security-Policy header value applied to all
// responses. The embedded SPA loads scripts and styles from same-origin
// files only (no inline scripts or inline style attributes), so a strict
// policy works without 'unsafe-inline'.
const cspPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"connect-src 'self'; " +
"img-src 'self'; " +
"font-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// CSP returns middleware that sets the Content-Security-Policy header on
// every response for defense-in-depth against XSS.
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspPolicy,
)
next.ServeHTTP(writer, request)
})
}
}

View File

@@ -8,8 +8,8 @@ import (
"git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper"
)
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
}
srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled {

View File

@@ -20,7 +20,7 @@ import (
"go.uber.org/fx"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
_ "github.com/joho/godotenv/autoload" // loads .env file
)

View File

@@ -2,6 +2,7 @@ package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAway = "AWAY"
CmdJoin = "JOIN"
CmdList = "LIST"
CmdLusers = "LUSERS"

391
pkg/irc/numerics.go Normal file
View File

@@ -0,0 +1,391 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
import (
"errors"
"fmt"
)
// IRCMessageType represents an IRC numeric reply or error code.
type IRCMessageType int //nolint:revive // Name requested by project owner.
// Name returns the standard IRC name for this numeric code
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
// Returns an empty string if the code is unknown.
func (t IRCMessageType) Name() string {
return names[t]
}
// String returns the name and numeric code in angle brackets
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
// If the code is unknown, returns "UNKNOWN <NNN>".
func (t IRCMessageType) String() string {
n := names[t]
if n == "" {
n = "UNKNOWN"
}
return fmt.Sprintf("%s <%03d>", n, int(t))
}
// Code returns the three-digit zero-padded string representation
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
func (t IRCMessageType) Code() string {
return fmt.Sprintf("%03d", int(t))
}
// Int returns the bare integer value of the numeric code.
func (t IRCMessageType) Int() int {
return int(t)
}
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
// FromInt converts an integer to an IRCMessageType, returning an error
// if the numeric code is not a known IRC reply or error code.
func FromInt(n int) (IRCMessageType, error) {
t := IRCMessageType(n)
if _, ok := names[t]; !ok {
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
}
return t, nil
}
// Connection registration replies (001-005).
const (
RplWelcome IRCMessageType = 1
RplYourHost IRCMessageType = 2
RplCreated IRCMessageType = 3
RplMyInfo IRCMessageType = 4
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
)
// Command responses (200-399).
const (
// RFC 2812 trace/stats/links replies (200-219).
RplTraceLink IRCMessageType = 200
RplTraceConnecting IRCMessageType = 201
RplTraceHandshake IRCMessageType = 202
RplTraceUnknown IRCMessageType = 203
RplTraceOperator IRCMessageType = 204
RplTraceUser IRCMessageType = 205
RplTraceServer IRCMessageType = 206
RplTraceService IRCMessageType = 207
RplTraceNewType IRCMessageType = 208
RplTraceClass IRCMessageType = 209
RplStatsLinkInfo IRCMessageType = 211
RplStatsCommands IRCMessageType = 212
RplStatsCLine IRCMessageType = 213
RplStatsNLine IRCMessageType = 214
RplStatsILine IRCMessageType = 215
RplStatsKLine IRCMessageType = 216
RplStatsQLine IRCMessageType = 217
RplStatsYLine IRCMessageType = 218
RplEndOfStats IRCMessageType = 219
RplUmodeIs IRCMessageType = 221
RplServList IRCMessageType = 234
RplServListEnd IRCMessageType = 235
RplStatsLLine IRCMessageType = 241
RplStatsUptime IRCMessageType = 242
RplStatsOLine IRCMessageType = 243
RplStatsHLine IRCMessageType = 244
RplLuserClient IRCMessageType = 251
RplLuserOp IRCMessageType = 252
RplLuserUnknown IRCMessageType = 253
RplLuserChannels IRCMessageType = 254
RplLuserMe IRCMessageType = 255
RplAdminMe IRCMessageType = 256
RplAdminLoc1 IRCMessageType = 257
RplAdminLoc2 IRCMessageType = 258
RplAdminEmail IRCMessageType = 259
RplTraceLog IRCMessageType = 261
RplTraceEnd IRCMessageType = 262
RplTryAgain IRCMessageType = 263
RplAway IRCMessageType = 301
RplUserHost IRCMessageType = 302
RplIson IRCMessageType = 303
RplUnaway IRCMessageType = 305
RplNowAway IRCMessageType = 306
RplWhoisUser IRCMessageType = 311
RplWhoisServer IRCMessageType = 312
RplWhoisOperator IRCMessageType = 313
RplWhoWasUser IRCMessageType = 314
RplEndOfWho IRCMessageType = 315
RplWhoisIdle IRCMessageType = 317
RplEndOfWhois IRCMessageType = 318
RplWhoisChannels IRCMessageType = 319
RplListStart IRCMessageType = 321
RplList IRCMessageType = 322
RplListEnd IRCMessageType = 323
RplChannelModeIs IRCMessageType = 324
RplUniqOpIs IRCMessageType = 325
RplCreationTime IRCMessageType = 329
RplNoTopic IRCMessageType = 331
RplTopic IRCMessageType = 332
RplTopicWhoTime IRCMessageType = 333
RplInviting IRCMessageType = 341
RplSummoning IRCMessageType = 342
RplInviteList IRCMessageType = 346
RplEndOfInviteList IRCMessageType = 347
RplExceptList IRCMessageType = 348
RplEndOfExceptList IRCMessageType = 349
RplVersion IRCMessageType = 351
RplWhoReply IRCMessageType = 352
RplNamReply IRCMessageType = 353
RplLinks IRCMessageType = 364
RplEndOfLinks IRCMessageType = 365
RplEndOfNames IRCMessageType = 366
RplBanList IRCMessageType = 367
RplEndOfBanList IRCMessageType = 368
RplEndOfWhowas IRCMessageType = 369
RplInfo IRCMessageType = 371
RplMotd IRCMessageType = 372
RplEndOfInfo IRCMessageType = 374
RplMotdStart IRCMessageType = 375
RplEndOfMotd IRCMessageType = 376
RplYoureOper IRCMessageType = 381
RplRehashing IRCMessageType = 382
RplYoureService IRCMessageType = 383
RplTime IRCMessageType = 391
RplUsersStart IRCMessageType = 392
RplUsers IRCMessageType = 393
RplEndOfUsers IRCMessageType = 394
RplNoUsers IRCMessageType = 395
)
// Error replies (400-599).
const (
ErrNoSuchNick IRCMessageType = 401
ErrNoSuchServer IRCMessageType = 402
ErrNoSuchChannel IRCMessageType = 403
ErrCannotSendToChan IRCMessageType = 404
ErrTooManyChannels IRCMessageType = 405
ErrWasNoSuchNick IRCMessageType = 406
ErrTooManyTargets IRCMessageType = 407
ErrNoSuchService IRCMessageType = 408
ErrNoOrigin IRCMessageType = 409
ErrNoRecipient IRCMessageType = 411
ErrNoTextToSend IRCMessageType = 412
ErrNoTopLevel IRCMessageType = 413
ErrWildTopLevel IRCMessageType = 414
ErrBadMask IRCMessageType = 415
ErrUnknownCommand IRCMessageType = 421
ErrNoMotd IRCMessageType = 422
ErrNoAdminInfo IRCMessageType = 423
ErrFileError IRCMessageType = 424
ErrNoNicknameGiven IRCMessageType = 431
ErrErroneusNickname IRCMessageType = 432
ErrNicknameInUse IRCMessageType = 433
ErrNickCollision IRCMessageType = 436
ErrUnavailResource IRCMessageType = 437
ErrUserNotInChannel IRCMessageType = 441
ErrNotOnChannel IRCMessageType = 442
ErrUserOnChannel IRCMessageType = 443
ErrNoLogin IRCMessageType = 444
ErrSummonDisabled IRCMessageType = 445
ErrUsersDisabled IRCMessageType = 446
ErrNotRegistered IRCMessageType = 451
ErrNeedMoreParams IRCMessageType = 461
ErrAlreadyRegistered IRCMessageType = 462
ErrNoPermForHost IRCMessageType = 463
ErrPasswdMismatch IRCMessageType = 464
ErrYoureBannedCreep IRCMessageType = 465
ErrYouWillBeBanned IRCMessageType = 466
ErrKeySet IRCMessageType = 467
ErrChannelIsFull IRCMessageType = 471
ErrUnknownMode IRCMessageType = 472
ErrInviteOnlyChan IRCMessageType = 473
ErrBannedFromChan IRCMessageType = 474
ErrBadChannelKey IRCMessageType = 475
ErrBadChanMask IRCMessageType = 476
ErrNoChanModes IRCMessageType = 477
ErrBanListFull IRCMessageType = 478
ErrNoPrivileges IRCMessageType = 481
ErrChanOpPrivsNeeded IRCMessageType = 482
ErrCantKillServer IRCMessageType = 483
ErrRestricted IRCMessageType = 484
ErrUniqOpPrivsNeeded IRCMessageType = 485
ErrNoOperHost IRCMessageType = 491
ErrUmodeUnknownFlag IRCMessageType = 501
ErrUsersDoNotMatch IRCMessageType = 502
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[IRCMessageType]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplBounce: "RPL_BOUNCE",
RplTraceLink: "RPL_TRACELINK",
RplTraceConnecting: "RPL_TRACECONNECTING",
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
RplTraceUnknown: "RPL_TRACEUNKNOWN",
RplTraceOperator: "RPL_TRACEOPERATOR",
RplTraceUser: "RPL_TRACEUSER",
RplTraceServer: "RPL_TRACESERVER",
RplTraceService: "RPL_TRACESERVICE",
RplTraceNewType: "RPL_TRACENEWTYPE",
RplTraceClass: "RPL_TRACECLASS",
RplStatsLinkInfo: "RPL_STATSLINKINFO",
RplStatsCommands: "RPL_STATSCOMMANDS",
RplStatsCLine: "RPL_STATSCLINE",
RplStatsNLine: "RPL_STATSNLINE",
RplStatsILine: "RPL_STATSILINE",
RplStatsKLine: "RPL_STATSKLINE",
RplStatsQLine: "RPL_STATSQLINE",
RplStatsYLine: "RPL_STATSYLINE",
RplEndOfStats: "RPL_ENDOFSTATS",
RplUmodeIs: "RPL_UMODEIS",
RplServList: "RPL_SERVLIST",
RplServListEnd: "RPL_SERVLISTEND",
RplStatsLLine: "RPL_STATSLLINE",
RplStatsUptime: "RPL_STATSUPTIME",
RplStatsOLine: "RPL_STATSOLINE",
RplStatsHLine: "RPL_STATSHLINE",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAdminMe: "RPL_ADMINME",
RplAdminLoc1: "RPL_ADMINLOC1",
RplAdminLoc2: "RPL_ADMINLOC2",
RplAdminEmail: "RPL_ADMINEMAIL",
RplTraceLog: "RPL_TRACELOG",
RplTraceEnd: "RPL_TRACEEND",
RplTryAgain: "RPL_TRYAGAIN",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplWhoWasUser: "RPL_WHOWASUSER",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplListStart: "RPL_LISTSTART",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplUniqOpIs: "RPL_UNIQOPIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplSummoning: "RPL_SUMMONING",
RplInviteList: "RPL_INVITELIST",
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
RplExceptList: "RPL_EXCEPTLIST",
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
RplVersion: "RPL_VERSION",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplLinks: "RPL_LINKS",
RplEndOfLinks: "RPL_ENDOFLINKS",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
RplInfo: "RPL_INFO",
RplMotd: "RPL_MOTD",
RplEndOfInfo: "RPL_ENDOFINFO",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
RplYoureOper: "RPL_YOUREOPER",
RplRehashing: "RPL_REHASHING",
RplYoureService: "RPL_YOURESERVICE",
RplTime: "RPL_TIME",
RplUsersStart: "RPL_USERSSTART",
RplUsers: "RPL_USERS",
RplEndOfUsers: "RPL_ENDOFUSERS",
RplNoUsers: "RPL_NOUSERS",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
ErrNoSuchService: "ERR_NOSUCHSERVICE",
ErrNoOrigin: "ERR_NOORIGIN",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrNoTopLevel: "ERR_NOTOPLEVEL",
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
ErrBadMask: "ERR_BADMASK",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoMotd: "ERR_NOMOTD",
ErrNoAdminInfo: "ERR_NOADMININFO",
ErrFileError: "ERR_FILEERROR",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrNickCollision: "ERR_NICKCOLLISION",
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrUserOnChannel: "ERR_USERONCHANNEL",
ErrNoLogin: "ERR_NOLOGIN",
ErrSummonDisabled: "ERR_SUMMONDISABLED",
ErrUsersDisabled: "ERR_USERSDISABLED",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrNoPermForHost: "ERR_NOPERMFORHOST",
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
ErrKeySet: "ERR_KEYSET",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrUnknownMode: "ERR_UNKNOWNMODE",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrBadChanMask: "ERR_BADCHANMASK",
ErrNoChanModes: "ERR_NOCHANMODES",
ErrBanListFull: "ERR_BANLISTFULL",
ErrNoPrivileges: "ERR_NOPRIVILEGES",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
ErrCantKillServer: "ERR_CANTKILLSERVER",
ErrRestricted: "ERR_RESTRICTED",
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
ErrNoOperHost: "ERR_NOOPERHOST",
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
//
// Deprecated: Use IRCMessageType.Name() instead.
func Name(code IRCMessageType) string {
return names[code]
}

163
pkg/irc/numerics_test.go Normal file
View File

@@ -0,0 +1,163 @@
package irc_test
import (
"errors"
"testing"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
func TestName(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME"},
{irc.RplBounce, "RPL_BOUNCE"},
{irc.RplLuserOp, "RPL_LUSEROP"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
}
for _, tc := range tests {
if got := tc.numeric.Name(); got != tc.want {
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestString(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME <001>"},
{irc.RplBounce, "RPL_BOUNCE <005>"},
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
}
for _, tc := range tests {
if got := tc.numeric.String(); got != tc.want {
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestCode(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "001"},
{irc.RplBounce, "005"},
{irc.RplLuserOp, "252"},
{irc.ErrCannotSendToChan, "404"},
}
for _, tc := range tests {
if got := tc.numeric.Code(); got != tc.want {
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestInt(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want int
}{
{irc.RplWelcome, 1},
{irc.RplBounce, 5},
{irc.RplLuserOp, 252},
{irc.ErrCannotSendToChan, 404},
}
for _, tc := range tests {
if got := tc.numeric.Int(); got != tc.want {
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
}
}
}
func TestFromInt_Known(t *testing.T) {
t.Parallel()
tests := []struct {
code int
want irc.IRCMessageType
}{
{1, irc.RplWelcome},
{5, irc.RplBounce},
{252, irc.RplLuserOp},
{404, irc.ErrCannotSendToChan},
{433, irc.ErrNicknameInUse},
}
for _, test := range tests {
got, err := irc.FromInt(test.code)
if err != nil {
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
continue
}
if got != test.want {
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
}
}
}
func TestFromInt_Unknown(t *testing.T) {
t.Parallel()
unknowns := []int{0, 999, 600, -1}
for _, code := range unknowns {
_, err := irc.FromInt(code)
if err == nil {
t.Errorf("FromInt(%d) expected error, got nil", code)
continue
}
if !errors.Is(err, irc.ErrUnknownNumeric) {
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
}
}
}
func TestUnknownNumeric_Name(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
if got := unknown.Name(); got != "" {
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
}
}
func TestUnknownNumeric_String(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
want := "UNKNOWN <999>"
if got := unknown.String(); got != want {
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
}
}
func TestDeprecatedNameFunc(t *testing.T) {
t.Parallel()
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
}
}

View File

@@ -124,7 +124,7 @@ function LoginScreen({ onLogin }) {
.catch(() => {});
const saved = localStorage.getItem("neoirc_token");
if (saved) {
api("/state")
api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token"));
}
@@ -135,20 +135,22 @@ function LoginScreen({ onLogin }) {
e.preventDefault();
setError("");
try {
const extraHeaders = {};
let hashcashStamp = "";
if (hashcashBitsRef.current > 0) {
setError("Computing proof-of-work...");
const stamp = await mintHashcash(
hashcashStamp = await mintHashcash(
hashcashBitsRef.current,
hashcashResourceRef.current,
);
extraHeaders["X-Hashcash"] = stamp;
setError("");
}
const reqBody = { nick: nick.trim() };
if (hashcashStamp) {
reqBody.pow_token = hashcashStamp;
}
const res = await api("/session", {
method: "POST",
body: JSON.stringify({ nick: nick.trim() }),
headers: extraHeaders,
body: JSON.stringify(reqBody),
});
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick);
@@ -398,7 +400,24 @@ function App() {
case "JOIN": {
const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
if (msg.to && msg.to.startsWith("#")) {
// Create a tab when the current user joins a channel
// (including JOINs from initChannelState on reconnect).
if (msg.from === nickRef.current) {
setTabs((prev) => {
if (
prev.find(
(t) => t.type === "channel" && t.name === msg.to,
)
)
return prev;
return [...prev, { type: "channel", name: msg.to }];
});
}
refreshMembers(msg.to);
}
break;
}
@@ -701,9 +720,13 @@ function App() {
setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`);
// Request MOTD on resumed sessions (new sessions get
// it automatically from the server during creation).
if (isResumed) {
// Request MOTD on resumed sessions (new sessions
// get it automatically from the server during
// creation). Channel state is initialized by the
// server via the message queue
// (?initChannelState=1), so we do not need to
// re-JOIN channels here.
try {
await api("/messages", {
method: "POST",
@@ -712,8 +735,11 @@ function App() {
} catch (e) {
// MOTD is non-critical.
}
return;
}
// Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);