Compare commits
11 Commits
d87aee80fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cab5784913 | |||
| 75cecd9803 | |||
| f2e7a6ec85 | |||
| 27df999942 | |||
| b19c8b5759 | |||
| 67446b36a1 | |||
| b1fd2f1b96 | |||
| c07f94a432 | |||
| a98e0ca349 | |||
| f287fdf6d1 | |||
| 687c958bd1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
|
web/dist/
|
||||||
/neoircd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,3 +1,13 @@
|
|||||||
|
# Web build stage — compile SPA from source
|
||||||
|
# node:22-alpine, 2026-03-09
|
||||||
|
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/src/ src/
|
||||||
|
COPY web/build.sh build.sh
|
||||||
|
RUN sh build.sh
|
||||||
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
@@ -5,6 +15,9 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
|
||||||
|
# without depending on the web-builder stage (lint should fail fast)
|
||||||
|
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
|
|
||||||
@@ -21,6 +34,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=web-builder /web/dist/ web/dist/
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
|
|||||||
288
README.md
288
README.md
@@ -249,8 +249,8 @@ Key properties:
|
|||||||
- **Ordered**: Queue entries have monotonically increasing IDs. Messages are
|
- **Ordered**: Queue entries have monotonically increasing IDs. Messages are
|
||||||
always delivered in order within a client's queue.
|
always delivered in order within a client's queue.
|
||||||
- **No delivery/read receipts** for channel messages. DM receipts are planned.
|
- **No delivery/read receipts** for channel messages. DM receipts are planned.
|
||||||
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48
|
- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`.
|
||||||
hours. Entries older than this are pruned.
|
Default is 30 days. Entries older than this are pruned.
|
||||||
|
|
||||||
### Long-Polling
|
### Long-Polling
|
||||||
|
|
||||||
@@ -987,14 +987,20 @@ the format:
|
|||||||
|
|
||||||
Create a new user session. This is the entry point for all clients.
|
Create a new user session. This is the entry point for all clients.
|
||||||
|
|
||||||
**Request:**
|
If the server requires hashcash proof-of-work (see
|
||||||
|
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
||||||
|
valid stamp in the `pow_token` field of the JSON request body. The required
|
||||||
|
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice"}
|
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
| Field | Type | Required | Constraints |
|
||||||
|--------|--------|----------|-------------|
|
|------------|--------|-------------|-------------|
|
||||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||||
|
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||||
|
|
||||||
**Response:** `201 Created`
|
**Response:** `201 Created`
|
||||||
```json
|
```json
|
||||||
@@ -1016,13 +1022,15 @@ Create a new user session. This is the entry point for all clients.
|
|||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||||
|
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled |
|
||||||
|
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||||
| 409 | `nick already taken` | Another active session holds this nick |
|
| 409 | `nick already taken` | Another active session holds this nick |
|
||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"alice"}' | jq -r .token)
|
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||||
echo $TOKEN
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1032,6 +1040,12 @@ Return the current user's session state.
|
|||||||
|
|
||||||
**Request:** No body. Requires auth.
|
**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`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1064,6 +1078,12 @@ curl -s http://localhost:8080/api/v1/state \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-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)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
|
|
||||||
Retrieve messages from the client's delivery queue. This is the primary
|
Retrieve messages from the client's delivery queue. This is the primary
|
||||||
@@ -1362,16 +1382,20 @@ Return server metadata. No authentication required.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My NeoIRC Server",
|
"name": "My NeoIRC Server",
|
||||||
|
"version": "0.1.0",
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42,
|
||||||
|
"hashcash_bits": 20
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---------|---------|-------------|
|
|-----------------|---------|-------------|
|
||||||
| `name` | string | Server display name |
|
| `name` | string | Server display name |
|
||||||
| `motd` | string | Message of the day |
|
| `version` | string | Server version |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
| `motd` | string | Message of the day |
|
||||||
|
| `users` | integer | Number of currently active user sessions |
|
||||||
|
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
|
||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
@@ -1610,6 +1634,10 @@ authenticity.
|
|||||||
termination.
|
termination.
|
||||||
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||||
Restrict this in production via reverse proxy configuration if needed.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1770,14 +1798,14 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
|||||||
|
|
||||||
### Data Lifecycle
|
### Data Lifecycle
|
||||||
|
|
||||||
- **Messages**: Stored indefinitely in the current implementation. Rotation
|
- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE`
|
||||||
per `MAX_HISTORY` is planned.
|
(default 30 days).
|
||||||
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
|
- **Client output queue entries**: Pruned automatically when older than
|
||||||
planned.
|
`QUEUE_MAX_AGE` (default 30 days).
|
||||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||||
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
|
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
|
||||||
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
|
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.
|
from all channels, broadcasts QUIT, and releases their nicks.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1794,9 +1822,9 @@ directory is also loaded automatically via
|
|||||||
| `PORT` | int | `8080` | HTTP listen port |
|
| `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`. |
|
| `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) |
|
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
|
||||||
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (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 | `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. |
|
| `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` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
|
| `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) |
|
| `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) |
|
| `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` |
|
| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` |
|
||||||
@@ -1805,6 +1833,7 @@ directory is also loaded automatically via
|
|||||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||||
|
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -1815,7 +1844,8 @@ SERVER_NAME=My NeoIRC Server
|
|||||||
MOTD=Welcome! Be excellent to each other.
|
MOTD=Welcome! Be excellent to each other.
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||||
SESSION_IDLE_TIMEOUT=24h
|
SESSION_IDLE_TIMEOUT=720h
|
||||||
|
NEOIRC_HASHCASH_BITS=20
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1838,26 +1868,16 @@ docker run -p 8080:8080 \
|
|||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a multi-stage build:
|
The Dockerfile is a four-stage build:
|
||||||
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
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)
|
compilation, not included in final image)
|
||||||
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
4. **final**: Minimal Alpine image with only the `neoircd` binary
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM golang:1.24-alpine AS builder
|
|
||||||
WORKDIR /src
|
|
||||||
RUN apk add --no-cache make
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN go build -o /neoircd ./cmd/neoircd/
|
|
||||||
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
|
|
||||||
|
|
||||||
FROM alpine:latest
|
|
||||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["neoircd"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
|
|
||||||
@@ -2098,62 +2118,102 @@ Clients should handle these message commands from the queue:
|
|||||||
|
|
||||||
## Rate Limiting & Abuse Prevention
|
## Rate Limiting & Abuse Prevention
|
||||||
|
|
||||||
Session creation (`POST /api/v1/session`) will require a
|
### Hashcash Proof-of-Work
|
||||||
|
|
||||||
|
Session creation (`POST /api/v1/session`) requires a
|
||||||
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
||||||
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
||||||
account registration, no IP-based rate limits that punish shared networks.
|
account registration, no IP-based rate limits that punish shared networks.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. Client requests a challenge: `GET /api/v1/challenge`
|
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
|
||||||
```json
|
field (e.g., `20`) indicating the required difficulty.
|
||||||
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
|
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
|
||||||
2. Server returns a nonce and a required difficulty (number of leading zero
|
bits.
|
||||||
bits in the SHA-256 hash)
|
3. Client includes the stamp in the `pow_token` field of the JSON request body when creating
|
||||||
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
|
a session: `POST /api/v1/session`.
|
||||||
has the required number of leading zero bits:
|
4. Server validates the stamp:
|
||||||
```
|
- Version is `1`
|
||||||
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
|
- Claimed bits ≥ required bits
|
||||||
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
|
- Resource matches the server name
|
||||||
...
|
- Date is within 48 hours (not expired, not too far in the future)
|
||||||
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
|
- SHA-256 hash has the required leading zero bits
|
||||||
```
|
- Stamp has not been used before (replay prevention)
|
||||||
4. Client submits the proof with the session request:
|
|
||||||
```json
|
|
||||||
POST /api/v1/session
|
|
||||||
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
|
|
||||||
```
|
|
||||||
5. Server verifies:
|
|
||||||
- Nonce was issued by this server and hasn't expired
|
|
||||||
- Nonce hasn't been used before (prevent replay)
|
|
||||||
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
|
|
||||||
- If valid, create the session normally
|
|
||||||
|
|
||||||
### Adaptive Difficulty
|
### Stamp Format
|
||||||
|
|
||||||
The required difficulty scales with server load. Under normal conditions, the
|
Standard hashcash format:
|
||||||
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
|
|
||||||
session creation rate increases, difficulty rises — making bulk session creation
|
|
||||||
exponentially more expensive for attackers while remaining cheap for legitimate
|
|
||||||
single-user connections.
|
|
||||||
|
|
||||||
| Server Load | Difficulty (bits) | Approx. Client CPU |
|
```
|
||||||
|--------------------|-------------------|--------------------|
|
1:bits:date:resource::counter
|
||||||
| Normal (< 100/min) | 16 | ~1ms |
|
```
|
||||||
| Elevated | 20 | ~15ms |
|
|
||||||
| High | 24 | ~250ms |
|
|
||||||
| Under attack | 28+ | ~4s+ |
|
|
||||||
|
|
||||||
Each additional bit of difficulty doubles the expected work. An attacker
|
| Field | Description |
|
||||||
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
|
|------------|-------------|
|
||||||
user creating one session needs ~4 seconds once and never again for the
|
| `1` | Version (always `1`) |
|
||||||
duration of their session.
|
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
|
||||||
|
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
|
||||||
|
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
|
||||||
|
| (empty) | Extension field (unused) |
|
||||||
|
| `counter` | Hex counter value found by the client to satisfy the PoW |
|
||||||
|
|
||||||
|
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
|
||||||
|
|
||||||
|
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
|
||||||
|
|
||||||
|
### Computing a Stamp
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pseudocode
|
||||||
|
bits = 20
|
||||||
|
resource = "neoirc"
|
||||||
|
date = "260310" # YYMMDD in UTC
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
loop:
|
||||||
|
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
|
||||||
|
hash = SHA-256(stamp)
|
||||||
|
if leading_zero_bits(hash) >= bits:
|
||||||
|
return stamp
|
||||||
|
counter++
|
||||||
|
```
|
||||||
|
|
||||||
|
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
|
||||||
|
average, taking roughly 0.5–2 seconds on modern hardware.
|
||||||
|
|
||||||
|
### Client Integration
|
||||||
|
|
||||||
|
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 `pow_token` field of the JSON body on `POST /api/v1/session`
|
||||||
|
|
||||||
|
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
||||||
|
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
|
||||||
|
|
||||||
|
| Value | Effect | Approx. Client CPU |
|
||||||
|
|-------|--------|---------------------|
|
||||||
|
| `0` | Disabled (no proof-of-work required) | — |
|
||||||
|
| `16` | Light protection | ~1ms |
|
||||||
|
| `20` | Default — good balance | ~0.5–2s |
|
||||||
|
| `24` | Strong protection | ~10–30s |
|
||||||
|
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||||
|
|
||||||
|
Each additional bit doubles the expected work. An attacker creating 1000
|
||||||
|
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||||
|
creating one session pays once and keeps their session.
|
||||||
|
|
||||||
### Why Hashcash and Not Rate Limits?
|
### Why Hashcash and Not Rate Limits?
|
||||||
|
|
||||||
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
||||||
The server only needs to verify a hash.
|
The server only needs to verify a single hash.
|
||||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||||
campuses, corporate networks, Tor exits). Every client computes their own
|
campuses, corporate networks, Tor exits). Every client computes their own
|
||||||
proof independently.
|
proof independently.
|
||||||
@@ -2161,36 +2221,9 @@ duration of their session.
|
|||||||
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
||||||
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
||||||
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
||||||
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
|
|
||||||
once. A botnet trying to create thousands of sessions pays exponentially more.
|
|
||||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||||
The proof computation is trivially implementable in any client.
|
The proof computation is trivially implementable in any client.
|
||||||
|
|
||||||
### Challenge Endpoint (Planned)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/challenge
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nonce": "a1b2c3d4e5f6...",
|
|
||||||
"difficulty": 20,
|
|
||||||
"algorithm": "sha256",
|
|
||||||
"expires": "2026-02-10T20:01:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|--------------|---------|-------------|
|
|
||||||
| `nonce` | string | Server-generated random hex string (32+ chars) |
|
|
||||||
| `difficulty` | integer | Required number of leading zero bits in the hash |
|
|
||||||
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
|
|
||||||
| `expires` | string | ISO 8601 expiry time for this challenge |
|
|
||||||
|
|
||||||
**Status:** Not yet implemented. Tracked for post-MVP.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -2219,9 +2252,9 @@ GET /api/v1/challenge
|
|||||||
|
|
||||||
### Post-MVP (Planned)
|
### Post-MVP (Planned)
|
||||||
|
|
||||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||||
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
||||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
||||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||||
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
||||||
- [x] **MODE command** — query channel and user modes (set not yet implemented)
|
- [x] **MODE command** — query channel and user modes (set not yet implemented)
|
||||||
@@ -2272,15 +2305,18 @@ neoirc/
|
|||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── neoircd/ # Server binary entry point
|
│ ├── neoircd/ # Server binary entry point
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ └── neoirc-cli/ # TUI client
|
│ └── neoirc-cli/ # TUI client entry point
|
||||||
│ ├── main.go # Command handling, poll loop
|
│ └── main.go # Minimal bootstrapping (calls internal/cli)
|
||||||
│ ├── ui.go # tview-based terminal UI
|
|
||||||
│ └── api/
|
|
||||||
│ ├── client.go # HTTP API client library
|
|
||||||
│ └── types.go # Request/response types
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── broker/ # In-memory pub/sub for long-poll notifications
|
│ ├── broker/ # In-memory pub/sub for long-poll notifications
|
||||||
│ │ └── broker.go
|
│ │ └── 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/ # Viper-based configuration
|
||||||
│ │ └── config.go
|
│ │ └── config.go
|
||||||
│ ├── db/ # Database access and migrations
|
│ ├── db/ # Database access and migrations
|
||||||
@@ -2306,10 +2342,14 @@ neoirc/
|
|||||||
│ └── http.go # HTTP timeouts
|
│ └── http.go # HTTP timeouts
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # go:embed directive for SPA
|
│ ├── embed.go # go:embed directive for SPA
|
||||||
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||||
│ ├── index.html
|
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||||
│ ├── style.css
|
│ ├── package-lock.json
|
||||||
│ └── app.js
|
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||||
|
│ │ ├── app.jsx
|
||||||
|
│ │ ├── index.html
|
||||||
|
│ │ └── style.css
|
||||||
|
│ └── dist/ # Generated at Docker build time (not committed)
|
||||||
├── schema/ # JSON Schema definitions (planned)
|
├── schema/ # JSON Schema definitions (planned)
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
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 use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -144,8 +151,14 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- 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).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `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
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -1,911 +1,8 @@
|
|||||||
// Package main is the entry point for the neoirc-cli client.
|
// Package main is the entry point for the neoirc-cli client.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
splitParts = 2
|
|
||||||
pollTimeout = 15
|
|
||||||
pollRetry = 2 * time.Second
|
|
||||||
timeFormat = "15:04"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App holds the application state.
|
|
||||||
type App struct {
|
|
||||||
ui *UI
|
|
||||||
client *api.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
nick string
|
|
||||||
target string
|
|
||||||
connected bool
|
|
||||||
lastQID int64
|
|
||||||
stopPoll chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &App{ //nolint:exhaustruct
|
cli.Run()
|
||||||
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,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
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/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/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 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
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/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
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 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -43,13 +43,30 @@ func NewClient(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
|
// If the server requires hashcash proof-of-work, it
|
||||||
|
// automatically fetches the difficulty and computes a
|
||||||
|
// valid stamp.
|
||||||
func (client *Client) CreateSession(
|
func (client *Client) CreateSession(
|
||||||
nick string,
|
nick string,
|
||||||
) (*SessionResponse, error) {
|
) (*SessionResponse, error) {
|
||||||
|
// Fetch server info to check for hashcash requirement.
|
||||||
|
info, err := client.GetServerInfo()
|
||||||
|
|
||||||
|
var hashcashStamp string
|
||||||
|
|
||||||
|
if err == nil && info.HashcashBits > 0 {
|
||||||
|
resource := info.Name
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
||||||
|
}
|
||||||
|
|
||||||
data, err := client.do(
|
data, err := client.do(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick},
|
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
79
internal/cli/api/hashcash.go
Normal file
79
internal/cli/api/hashcash.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package neoircapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// bitsPerByte is the number of bits in a byte.
|
||||||
|
bitsPerByte = 8
|
||||||
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||||
|
fullByteMask = 0xFF
|
||||||
|
// counterSpace is the range for random counter seeds.
|
||||||
|
counterSpace = 1 << 48
|
||||||
|
)
|
||||||
|
|
||||||
|
// MintHashcash computes a hashcash stamp with the given
|
||||||
|
// difficulty (leading zero bits) and resource string.
|
||||||
|
func MintHashcash(bits int, resource string) string {
|
||||||
|
date := time.Now().UTC().Format("060102")
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s::", bits, date, resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
counter := randomCounter()
|
||||||
|
stamp := prefix + counter
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
|
// leading zero bits.
|
||||||
|
func hasLeadingZeroBits(
|
||||||
|
hash []byte,
|
||||||
|
numBits int,
|
||||||
|
) bool {
|
||||||
|
fullBytes := numBits / bitsPerByte
|
||||||
|
remainBits := numBits % bitsPerByte
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(
|
||||||
|
fullByteMask << (bitsPerByte - remainBits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomCounter generates a random hex counter string.
|
||||||
|
func randomCounter() string {
|
||||||
|
counterVal, err := rand.Int(
|
||||||
|
rand.Reader, big.NewInt(counterSpace),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to timestamp-based counter on error.
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(counterVal.Bytes())
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import "time"
|
|||||||
|
|
||||||
// SessionRequest is the body for POST /api/v1/session.
|
// SessionRequest is the body for POST /api/v1/session.
|
||||||
type SessionRequest struct {
|
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.
|
// SessionResponse is the response from session creation.
|
||||||
@@ -63,9 +64,10 @@ type Channel struct {
|
|||||||
|
|
||||||
// ServerInfo is the response from GET /api/v1/server.
|
// ServerInfo is the response from GET /api/v1/server.
|
||||||
type ServerInfo struct {
|
type ServerInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MOTD string `json:"motd"`
|
MOTD string `json:"motd"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
912
internal/cli/app.go
Normal file
912
internal/cli/app.go
Normal 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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,12 +38,14 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaxHistory int
|
MessageMaxAge string
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
|
QueueMaxAge string
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
|
HashcashBits int
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -68,12 +70,14 @@ func New(
|
|||||||
viper.SetDefault("SENTRY_DSN", "")
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MAX_HISTORY", "10000")
|
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
|
viper.SetDefault("QUEUE_MAX_AGE", "720h")
|
||||||
viper.SetDefault("MOTD", defaultMOTD)
|
viper.SetDefault("MOTD", defaultMOTD)
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
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()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,12 +96,14 @@ func New(
|
|||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
||||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||||
|
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
|
||||||
MOTD: viper.GetString("MOTD"),
|
MOTD: viper.GetString("MOTD"),
|
||||||
ServerName: viper.GetString("SERVER_NAME"),
|
ServerName: viper.GetString("SERVER_NAME"),
|
||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,14 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -137,12 +139,14 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
20
internal/db/errors.go
Normal file
20
internal/db/errors.go
Normal 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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,7 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ func generateToken() (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
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.
|
// IRCMessage is the IRC envelope for all messages.
|
||||||
type IRCMessage struct {
|
type IRCMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -105,12 +114,14 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -143,6 +154,8 @@ func (database *Database) GetSessionByToken(
|
|||||||
nick string
|
nick string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT s.id, c.id, s.nick
|
`SELECT s.id, c.id, s.nick
|
||||||
@@ -150,7 +163,7 @@ func (database *Database) GetSessionByToken(
|
|||||||
INNER JOIN sessions s
|
INNER JOIN sessions s
|
||||||
ON s.id = c.session_id
|
ON s.id = c.session_id
|
||||||
WHERE c.token = ?`,
|
WHERE c.token = ?`,
|
||||||
token,
|
tokenHash,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
@@ -733,8 +746,8 @@ func scanMessages(
|
|||||||
code, _ := strconv.Atoi(msg.Command)
|
code, _ := strconv.Atoi(msg.Command)
|
||||||
msg.Code = code
|
msg.Code = code
|
||||||
|
|
||||||
if name := irc.Name(code); name != "" {
|
if mt, err := irc.FromInt(code); err == nil {
|
||||||
msg.Command = name
|
msg.Command = mt.Name()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,3 +1109,160 @@ func (database *Database) GetSessionCreatedAt(
|
|||||||
|
|
||||||
return createdAt, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
nick TEXT NOT NULL UNIQUE,
|
nick TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen 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,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
topic TEXT NOT NULL DEFAULT '',
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
|
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||||
|
topic_set_at DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"github.com/go-chi/chi"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -70,11 +71,10 @@ func (hdlr *Handlers) requireAuth(
|
|||||||
sessionID, clientID, nick, err :=
|
sessionID, clientID, nick, err :=
|
||||||
hdlr.authSession(request)
|
hdlr.authSession(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
writer, request,
|
"error": "not registered",
|
||||||
"unauthorized",
|
"numeric": irc.ErrNotRegistered,
|
||||||
http.StatusUnauthorized,
|
}, http.StatusUnauthorized)
|
||||||
)
|
|
||||||
|
|
||||||
return 0, 0, "", false
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,8 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
type createRequest struct {
|
type createRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
|
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload createRequest
|
var payload createRequest
|
||||||
@@ -161,6 +162,32 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
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)
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||||
|
|
||||||
if !validNickRe.MatchString(payload.Nick) {
|
if !validNickRe.MatchString(payload.Nick) {
|
||||||
@@ -199,7 +226,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -397,12 +424,12 @@ func (hdlr *Handlers) serverName() string {
|
|||||||
func (hdlr *Handlers) enqueueNumeric(
|
func (hdlr *Handlers) enqueueNumeric(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
clientID int64,
|
clientID int64,
|
||||||
code int,
|
code irc.IRCMessageType,
|
||||||
nick string,
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
command := fmt.Sprintf("%03d", code)
|
command := code.Code()
|
||||||
|
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -444,13 +471,17 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// 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 {
|
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
sessionID, _, nick, ok :=
|
sessionID, clientID, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -472,6 +503,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if request.URL.Query().Get("initChannelState") == "1" {
|
||||||
|
hdlr.initChannelState(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": nick,
|
||||||
@@ -480,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.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -753,6 +836,11 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
|
case irc.CmdAway:
|
||||||
|
hdlr.handleAway(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -863,8 +951,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
irc.ErrNoRecipient, nick, []string{command},
|
||||||
"Not enough parameters",
|
"No recipient given",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -878,8 +966,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
irc.ErrNoTextToSend, nick, []string{command},
|
||||||
"Not enough parameters",
|
"No text to send",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -912,7 +1000,7 @@ func (hdlr *Handlers) respondIRCError(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
code int,
|
code irc.IRCMessageType,
|
||||||
nick string,
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
@@ -966,8 +1054,8 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNotOnChannel, nick, []string{target},
|
irc.ErrCannotSendToChan, nick, []string{target},
|
||||||
"You're not on that channel",
|
"Cannot send to channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1063,6 +1151,19 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
return
|
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,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": msgUUID, "status": "sent"},
|
map[string]string{"id": msgUUID, "status": "sent"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1173,14 +1274,25 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
chInfo, err := hdlr.params.Database.GetChannelByName(
|
hdlr.deliverTopicNumerics(
|
||||||
ctx, channel,
|
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(
|
channels, listErr := hdlr.params.Database.ListChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
@@ -1202,14 +1314,39 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
ctx, clientID, irc.RplTopic, nick,
|
ctx, clientID, irc.RplTopic, nick,
|
||||||
[]string{channel}, topic,
|
[]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 {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNoTopic, nick,
|
ctx, clientID, irc.RplNoTopic, nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]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(
|
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
@@ -1232,8 +1369,6 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, irc.RplEndOfNames, nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handlePart(
|
func (hdlr *Handlers) handlePart(
|
||||||
@@ -1371,7 +1506,7 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||||
@@ -1517,8 +1652,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
setErr := hdlr.params.Database.SetTopic(
|
setErr := hdlr.params.Database.SetTopicMeta(
|
||||||
request.Context(), channel, topic,
|
request.Context(), channel, topic, nick,
|
||||||
)
|
)
|
||||||
if setErr != nil {
|
if setErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -1545,6 +1680,25 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplTopic, nick, []string{channel}, topic,
|
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.broker.Notify(sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -1934,6 +2088,11 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 317 RPL_WHOISIDLE
|
||||||
|
hdlr.deliverWhoisIdle(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
// 319 RPL_WHOISCHANNELS
|
// 319 RPL_WHOISCHANNELS
|
||||||
hdlr.deliverWhoisChannels(
|
hdlr.deliverWhoisChannels(
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
@@ -2335,10 +2494,111 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
resp := map[string]any{
|
||||||
"name": hdlr.params.Config.ServerName,
|
"name": hdlr.params.Config.ServerName,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"version": hdlr.params.Globals.Version,
|
||||||
"users": users,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
}, http.StatusOK)
|
"users": users,
|
||||||
|
}
|
||||||
|
|
||||||
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
|
resp["hashcash_bits"] = hdlr.params.Config.HashcashBits
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(
|
||||||
|
writer, request, resp, http.StatusOK,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAway handles the AWAY command. An empty body
|
||||||
|
// clears the away status; a non-empty body sets it.
|
||||||
|
func (hdlr *Handlers) handleAway(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
|
||||||
|
awayMsg := ""
|
||||||
|
if len(lines) > 0 {
|
||||||
|
awayMsg = strings.Join(lines, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.params.Database.SetAway(
|
||||||
|
ctx, sessionID, awayMsg,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error("set away failed", "error", err)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if awayMsg == "" {
|
||||||
|
// 305 RPL_UNAWAY
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUnaway, nick, nil,
|
||||||
|
"You are no longer marked as being away",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 306 RPL_NOWAWAY
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplNowAway, nick, nil,
|
||||||
|
"You have been marked as being away",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
|
||||||
|
// time and signon time.
|
||||||
|
func (hdlr *Handlers) deliverWhoisIdle(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
nick, queryNick string,
|
||||||
|
targetSID int64,
|
||||||
|
) {
|
||||||
|
lastSeen, lsErr := hdlr.params.Database.
|
||||||
|
GetSessionLastSeen(ctx, targetSID)
|
||||||
|
if lsErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt, caErr := hdlr.params.Database.
|
||||||
|
GetSessionCreatedAt(ctx, targetSID)
|
||||||
|
if caErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idleSeconds := int64(time.Since(lastSeen).Seconds())
|
||||||
|
if idleSeconds < 0 {
|
||||||
|
idleSeconds = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
signonUnix := strconv.FormatInt(
|
||||||
|
createdAt.Unix(), 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplWhoisIdle, nick,
|
||||||
|
[]string{
|
||||||
|
queryNick,
|
||||||
|
strconv.FormatInt(idleSeconds, 10),
|
||||||
|
signonUnix,
|
||||||
|
},
|
||||||
|
"seconds idle, signon time",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func newTestServer(
|
|||||||
|
|
||||||
cfg.DBURL = dbURL
|
cfg.DBURL = dbURL
|
||||||
cfg.Port = 0
|
cfg.Port = 0
|
||||||
|
cfg.HashcashBits = 0
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
},
|
},
|
||||||
@@ -810,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
if !findNumeric(msgs, "412") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected ERR_NOTEXTTOSEND (412), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -834,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
if !findNumeric(msgs, "411") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected ERR_NORECIPIENT (411), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -869,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "442") {
|
if !findNumeric(msgs, "404") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -182,6 +184,12 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
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{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -31,7 +32,7 @@ type Params struct {
|
|||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
@@ -39,6 +40,7 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
hashcashVal *hashcash.Validator
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +49,17 @@ func New(
|
|||||||
lifecycle fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
|
resource := params.Config.ServerName
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -200,4 +208,77 @@ func (hdlr *Handlers) runCleanup(
|
|||||||
"deleted", deleted,
|
"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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Package hashcash implements SHA-256-based hashcash
|
||||||
|
// proof-of-work validation for abuse prevention.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
||||||
|
//
|
||||||
|
// The SHA-256 hash of the entire stamp string must have
|
||||||
|
// at least `bits` leading zero bits.
|
||||||
|
package hashcash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// stampVersion is the only supported hashcash version.
|
||||||
|
stampVersion = "1"
|
||||||
|
// stampFields is the number of fields in a stamp.
|
||||||
|
stampFields = 6
|
||||||
|
// maxStampAge is how old a stamp can be before
|
||||||
|
// rejection.
|
||||||
|
maxStampAge = 48 * time.Hour
|
||||||
|
// maxFutureSkew allows stamps slightly in the future.
|
||||||
|
maxFutureSkew = 1 * time.Hour
|
||||||
|
// pruneInterval controls how often expired stamps are
|
||||||
|
// removed from the spent set.
|
||||||
|
pruneInterval = 10 * time.Minute
|
||||||
|
// dateFormatShort is the YYMMDD date layout.
|
||||||
|
dateFormatShort = "060102"
|
||||||
|
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
||||||
|
dateFormatLong = "060102150405"
|
||||||
|
// dateShortLen is the length of YYMMDD.
|
||||||
|
dateShortLen = 6
|
||||||
|
// dateLongLen is the length of YYMMDDHHMMSS.
|
||||||
|
dateLongLen = 12
|
||||||
|
// bitsPerByte is the number of bits in a byte.
|
||||||
|
bitsPerByte = 8
|
||||||
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||||
|
fullByteMask = 0xFF
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidFields = errors.New("invalid stamp field count")
|
||||||
|
errBadVersion = errors.New("unsupported stamp version")
|
||||||
|
errInsufficientBits = errors.New("insufficient difficulty")
|
||||||
|
errWrongResource = errors.New("wrong resource")
|
||||||
|
errStampExpired = errors.New("stamp expired")
|
||||||
|
errStampFuture = errors.New("stamp date in future")
|
||||||
|
errProofFailed = errors.New("proof-of-work failed")
|
||||||
|
errStampReused = errors.New("stamp already used")
|
||||||
|
errBadDateFormat = errors.New("unrecognized date format")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator checks hashcash stamps for validity and
|
||||||
|
// prevents replay attacks via an in-memory spent set.
|
||||||
|
type Validator struct {
|
||||||
|
resource string
|
||||||
|
mu sync.Mutex
|
||||||
|
spent map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator creates a Validator for the given resource.
|
||||||
|
func NewValidator(resource string) *Validator {
|
||||||
|
validator := &Validator{
|
||||||
|
resource: resource,
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
spent: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
go validator.pruneLoop()
|
||||||
|
|
||||||
|
return validator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks a hashcash stamp. It returns nil if the
|
||||||
|
// stamp is valid and has not been seen before.
|
||||||
|
func (v *Validator) Validate(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if requiredBits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(stamp, ":")
|
||||||
|
if len(parts) != stampFields {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: expected %d, got %d",
|
||||||
|
errInvalidFields, stampFields, len(parts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := parts[0]
|
||||||
|
bitsStr := parts[1]
|
||||||
|
dateStr := parts[2]
|
||||||
|
resource := parts[3]
|
||||||
|
|
||||||
|
if err := v.validateHeader(
|
||||||
|
version, bitsStr, resource, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, err := parseStampDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTime(stampTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateProof(
|
||||||
|
stamp, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.checkAndRecordStamp(stamp, stampTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) validateHeader(
|
||||||
|
version, bitsStr, resource string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if version != stampVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", errBadVersion, version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedBits, err := strconv.Atoi(bitsStr)
|
||||||
|
if err != nil || claimedBits < requiredBits {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d bits",
|
||||||
|
errInsufficientBits, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource != v.resource {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errWrongResource, resource, v.resource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTime(stampTime time.Time) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if now.Sub(stampTime) > maxStampAge {
|
||||||
|
return errStampExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if stampTime.Sub(now) > maxFutureSkew {
|
||||||
|
return errStampFuture
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProof(stamp string, requiredBits int) error {
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d leading zero bits",
|
||||||
|
errProofFailed, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) checkAndRecordStamp(
|
||||||
|
stamp string,
|
||||||
|
stampTime time.Time,
|
||||||
|
) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := v.spent[stamp]; ok {
|
||||||
|
return errStampReused
|
||||||
|
}
|
||||||
|
|
||||||
|
v.spent[stamp] = stampTime
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if the hash has at least n
|
||||||
|
// leading zero bits.
|
||||||
|
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
||||||
|
fullBytes := numBits / bitsPerByte
|
||||||
|
remainBits := numBits % bitsPerByte
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(
|
||||||
|
fullByteMask << (bitsPerByte - remainBits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStampDate parses a hashcash date stamp.
|
||||||
|
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
||||||
|
func parseStampDate(dateStr string) (time.Time, error) {
|
||||||
|
switch len(dateStr) {
|
||||||
|
case dateShortLen:
|
||||||
|
parsed, err := time.Parse(
|
||||||
|
dateFormatShort, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"parse date: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
case dateLongLen:
|
||||||
|
parsed, err := time.Parse(
|
||||||
|
dateFormatLong, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"parse date: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"%w: %q", errBadDateFormat, dateStr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLoop periodically removes expired stamps from the
|
||||||
|
// spent set.
|
||||||
|
func (v *Validator) pruneLoop() {
|
||||||
|
ticker := time.NewTicker(pruneInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
v.prune()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) prune() {
|
||||||
|
cutoff := time.Now().Add(-maxStampAge)
|
||||||
|
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
for stamp, stampTime := range v.spent {
|
||||||
|
if stampTime.Before(cutoff) {
|
||||||
|
delete(v.spent, stamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
internal/hashcash/hashcash_test.go
Normal file
261
internal/hashcash/hashcash_test.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome = 1
|
|
||||||
RplYourHost = 2
|
|
||||||
RplCreated = 3
|
|
||||||
RplMyInfo = 4
|
|
||||||
RplIsupport = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
RplUmodeIs = 221
|
|
||||||
RplLuserClient = 251
|
|
||||||
RplLuserOp = 252
|
|
||||||
RplLuserUnknown = 253
|
|
||||||
RplLuserChannels = 254
|
|
||||||
RplLuserMe = 255
|
|
||||||
RplAway = 301
|
|
||||||
RplUserHost = 302
|
|
||||||
RplIson = 303
|
|
||||||
RplUnaway = 305
|
|
||||||
RplNowAway = 306
|
|
||||||
RplWhoisUser = 311
|
|
||||||
RplWhoisServer = 312
|
|
||||||
RplWhoisOperator = 313
|
|
||||||
RplEndOfWho = 315
|
|
||||||
RplWhoisIdle = 317
|
|
||||||
RplEndOfWhois = 318
|
|
||||||
RplWhoisChannels = 319
|
|
||||||
RplList = 322
|
|
||||||
RplListEnd = 323
|
|
||||||
RplChannelModeIs = 324
|
|
||||||
RplCreationTime = 329
|
|
||||||
RplNoTopic = 331
|
|
||||||
RplTopic = 332
|
|
||||||
RplTopicWhoTime = 333
|
|
||||||
RplInviting = 341
|
|
||||||
RplWhoReply = 352
|
|
||||||
RplNamReply = 353
|
|
||||||
RplEndOfNames = 366
|
|
||||||
RplBanList = 367
|
|
||||||
RplEndOfBanList = 368
|
|
||||||
RplMotd = 372
|
|
||||||
RplMotdStart = 375
|
|
||||||
RplEndOfMotd = 376
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick = 401
|
|
||||||
ErrNoSuchServer = 402
|
|
||||||
ErrNoSuchChannel = 403
|
|
||||||
ErrCannotSendToChan = 404
|
|
||||||
ErrTooManyChannels = 405
|
|
||||||
ErrNoRecipient = 411
|
|
||||||
ErrNoTextToSend = 412
|
|
||||||
ErrUnknownCommand = 421
|
|
||||||
ErrNoNicknameGiven = 431
|
|
||||||
ErrErroneusNickname = 432
|
|
||||||
ErrNicknameInUse = 433
|
|
||||||
ErrUserNotInChannel = 441
|
|
||||||
ErrNotOnChannel = 442
|
|
||||||
ErrNotRegistered = 451
|
|
||||||
ErrNeedMoreParams = 461
|
|
||||||
ErrAlreadyRegistered = 462
|
|
||||||
ErrChannelIsFull = 471
|
|
||||||
ErrInviteOnlyChan = 473
|
|
||||||
ErrBannedFromChan = 474
|
|
||||||
ErrBadChannelKey = 475
|
|
||||||
ErrChanOpPrivsNeeded = 482
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[int]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplIsupport: "RPL_ISUPPORT",
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
func Name(code int) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
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"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
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.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
|
srv.router.Use(srv.mw.CSP())
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
|
CmdAway = "AWAY"
|
||||||
CmdJoin = "JOIN"
|
CmdJoin = "JOIN"
|
||||||
CmdList = "LIST"
|
CmdList = "LIST"
|
||||||
CmdLusers = "LUSERS"
|
CmdLusers = "LUSERS"
|
||||||
391
pkg/irc/numerics.go
Normal file
391
pkg/irc/numerics.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
// Package irc provides constants and utilities for the
|
||||||
|
// IRC protocol, including numeric reply codes from
|
||||||
|
// RFC 1459 and RFC 2812, and standard command names.
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IRCMessageType represents an IRC numeric reply or error code.
|
||||||
|
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
||||||
|
|
||||||
|
// Name returns the standard IRC name for this numeric code
|
||||||
|
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
||||||
|
// Returns an empty string if the code is unknown.
|
||||||
|
func (t IRCMessageType) Name() string {
|
||||||
|
return names[t]
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the name and numeric code in angle brackets
|
||||||
|
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
||||||
|
// If the code is unknown, returns "UNKNOWN <NNN>".
|
||||||
|
func (t IRCMessageType) String() string {
|
||||||
|
n := names[t]
|
||||||
|
if n == "" {
|
||||||
|
n = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s <%03d>", n, int(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code returns the three-digit zero-padded string representation
|
||||||
|
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
||||||
|
func (t IRCMessageType) Code() string {
|
||||||
|
return fmt.Sprintf("%03d", int(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int returns the bare integer value of the numeric code.
|
||||||
|
func (t IRCMessageType) Int() int {
|
||||||
|
return int(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
||||||
|
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
||||||
|
|
||||||
|
// FromInt converts an integer to an IRCMessageType, returning an error
|
||||||
|
// if the numeric code is not a known IRC reply or error code.
|
||||||
|
func FromInt(n int) (IRCMessageType, error) {
|
||||||
|
t := IRCMessageType(n)
|
||||||
|
if _, ok := names[t]; !ok {
|
||||||
|
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection registration replies (001-005).
|
||||||
|
const (
|
||||||
|
RplWelcome IRCMessageType = 1
|
||||||
|
RplYourHost IRCMessageType = 2
|
||||||
|
RplCreated IRCMessageType = 3
|
||||||
|
RplMyInfo IRCMessageType = 4
|
||||||
|
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
||||||
|
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command responses (200-399).
|
||||||
|
const (
|
||||||
|
// RFC 2812 trace/stats/links replies (200-219).
|
||||||
|
RplTraceLink IRCMessageType = 200
|
||||||
|
RplTraceConnecting IRCMessageType = 201
|
||||||
|
RplTraceHandshake IRCMessageType = 202
|
||||||
|
RplTraceUnknown IRCMessageType = 203
|
||||||
|
RplTraceOperator IRCMessageType = 204
|
||||||
|
RplTraceUser IRCMessageType = 205
|
||||||
|
RplTraceServer IRCMessageType = 206
|
||||||
|
RplTraceService IRCMessageType = 207
|
||||||
|
RplTraceNewType IRCMessageType = 208
|
||||||
|
RplTraceClass IRCMessageType = 209
|
||||||
|
RplStatsLinkInfo IRCMessageType = 211
|
||||||
|
RplStatsCommands IRCMessageType = 212
|
||||||
|
RplStatsCLine IRCMessageType = 213
|
||||||
|
RplStatsNLine IRCMessageType = 214
|
||||||
|
RplStatsILine IRCMessageType = 215
|
||||||
|
RplStatsKLine IRCMessageType = 216
|
||||||
|
RplStatsQLine IRCMessageType = 217
|
||||||
|
RplStatsYLine IRCMessageType = 218
|
||||||
|
RplEndOfStats IRCMessageType = 219
|
||||||
|
|
||||||
|
RplUmodeIs IRCMessageType = 221
|
||||||
|
RplServList IRCMessageType = 234
|
||||||
|
RplServListEnd IRCMessageType = 235
|
||||||
|
RplStatsLLine IRCMessageType = 241
|
||||||
|
RplStatsUptime IRCMessageType = 242
|
||||||
|
RplStatsOLine IRCMessageType = 243
|
||||||
|
RplStatsHLine IRCMessageType = 244
|
||||||
|
RplLuserClient IRCMessageType = 251
|
||||||
|
RplLuserOp IRCMessageType = 252
|
||||||
|
RplLuserUnknown IRCMessageType = 253
|
||||||
|
|
||||||
|
RplLuserChannels IRCMessageType = 254
|
||||||
|
RplLuserMe IRCMessageType = 255
|
||||||
|
RplAdminMe IRCMessageType = 256
|
||||||
|
RplAdminLoc1 IRCMessageType = 257
|
||||||
|
RplAdminLoc2 IRCMessageType = 258
|
||||||
|
RplAdminEmail IRCMessageType = 259
|
||||||
|
RplTraceLog IRCMessageType = 261
|
||||||
|
RplTraceEnd IRCMessageType = 262
|
||||||
|
RplTryAgain IRCMessageType = 263
|
||||||
|
|
||||||
|
RplAway IRCMessageType = 301
|
||||||
|
RplUserHost IRCMessageType = 302
|
||||||
|
RplIson IRCMessageType = 303
|
||||||
|
RplUnaway IRCMessageType = 305
|
||||||
|
RplNowAway IRCMessageType = 306
|
||||||
|
RplWhoisUser IRCMessageType = 311
|
||||||
|
RplWhoisServer IRCMessageType = 312
|
||||||
|
RplWhoisOperator IRCMessageType = 313
|
||||||
|
RplWhoWasUser IRCMessageType = 314
|
||||||
|
RplEndOfWho IRCMessageType = 315
|
||||||
|
RplWhoisIdle IRCMessageType = 317
|
||||||
|
RplEndOfWhois IRCMessageType = 318
|
||||||
|
RplWhoisChannels IRCMessageType = 319
|
||||||
|
RplListStart IRCMessageType = 321
|
||||||
|
RplList IRCMessageType = 322
|
||||||
|
RplListEnd IRCMessageType = 323
|
||||||
|
RplChannelModeIs IRCMessageType = 324
|
||||||
|
|
||||||
|
RplUniqOpIs IRCMessageType = 325
|
||||||
|
RplCreationTime IRCMessageType = 329
|
||||||
|
RplNoTopic IRCMessageType = 331
|
||||||
|
RplTopic IRCMessageType = 332
|
||||||
|
RplTopicWhoTime IRCMessageType = 333
|
||||||
|
RplInviting IRCMessageType = 341
|
||||||
|
RplSummoning IRCMessageType = 342
|
||||||
|
RplInviteList IRCMessageType = 346
|
||||||
|
RplEndOfInviteList IRCMessageType = 347
|
||||||
|
RplExceptList IRCMessageType = 348
|
||||||
|
RplEndOfExceptList IRCMessageType = 349
|
||||||
|
RplVersion IRCMessageType = 351
|
||||||
|
RplWhoReply IRCMessageType = 352
|
||||||
|
RplNamReply IRCMessageType = 353
|
||||||
|
RplLinks IRCMessageType = 364
|
||||||
|
RplEndOfLinks IRCMessageType = 365
|
||||||
|
RplEndOfNames IRCMessageType = 366
|
||||||
|
RplBanList IRCMessageType = 367
|
||||||
|
RplEndOfBanList IRCMessageType = 368
|
||||||
|
RplEndOfWhowas IRCMessageType = 369
|
||||||
|
RplInfo IRCMessageType = 371
|
||||||
|
RplMotd IRCMessageType = 372
|
||||||
|
RplEndOfInfo IRCMessageType = 374
|
||||||
|
RplMotdStart IRCMessageType = 375
|
||||||
|
RplEndOfMotd IRCMessageType = 376
|
||||||
|
RplYoureOper IRCMessageType = 381
|
||||||
|
RplRehashing IRCMessageType = 382
|
||||||
|
RplYoureService IRCMessageType = 383
|
||||||
|
RplTime IRCMessageType = 391
|
||||||
|
RplUsersStart IRCMessageType = 392
|
||||||
|
RplUsers IRCMessageType = 393
|
||||||
|
RplEndOfUsers IRCMessageType = 394
|
||||||
|
RplNoUsers IRCMessageType = 395
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error replies (400-599).
|
||||||
|
const (
|
||||||
|
ErrNoSuchNick IRCMessageType = 401
|
||||||
|
ErrNoSuchServer IRCMessageType = 402
|
||||||
|
ErrNoSuchChannel IRCMessageType = 403
|
||||||
|
ErrCannotSendToChan IRCMessageType = 404
|
||||||
|
ErrTooManyChannels IRCMessageType = 405
|
||||||
|
ErrWasNoSuchNick IRCMessageType = 406
|
||||||
|
ErrTooManyTargets IRCMessageType = 407
|
||||||
|
ErrNoSuchService IRCMessageType = 408
|
||||||
|
ErrNoOrigin IRCMessageType = 409
|
||||||
|
ErrNoRecipient IRCMessageType = 411
|
||||||
|
ErrNoTextToSend IRCMessageType = 412
|
||||||
|
ErrNoTopLevel IRCMessageType = 413
|
||||||
|
ErrWildTopLevel IRCMessageType = 414
|
||||||
|
ErrBadMask IRCMessageType = 415
|
||||||
|
ErrUnknownCommand IRCMessageType = 421
|
||||||
|
ErrNoMotd IRCMessageType = 422
|
||||||
|
ErrNoAdminInfo IRCMessageType = 423
|
||||||
|
ErrFileError IRCMessageType = 424
|
||||||
|
ErrNoNicknameGiven IRCMessageType = 431
|
||||||
|
ErrErroneusNickname IRCMessageType = 432
|
||||||
|
ErrNicknameInUse IRCMessageType = 433
|
||||||
|
ErrNickCollision IRCMessageType = 436
|
||||||
|
ErrUnavailResource IRCMessageType = 437
|
||||||
|
|
||||||
|
ErrUserNotInChannel IRCMessageType = 441
|
||||||
|
ErrNotOnChannel IRCMessageType = 442
|
||||||
|
ErrUserOnChannel IRCMessageType = 443
|
||||||
|
ErrNoLogin IRCMessageType = 444
|
||||||
|
ErrSummonDisabled IRCMessageType = 445
|
||||||
|
ErrUsersDisabled IRCMessageType = 446
|
||||||
|
ErrNotRegistered IRCMessageType = 451
|
||||||
|
ErrNeedMoreParams IRCMessageType = 461
|
||||||
|
ErrAlreadyRegistered IRCMessageType = 462
|
||||||
|
ErrNoPermForHost IRCMessageType = 463
|
||||||
|
ErrPasswdMismatch IRCMessageType = 464
|
||||||
|
ErrYoureBannedCreep IRCMessageType = 465
|
||||||
|
ErrYouWillBeBanned IRCMessageType = 466
|
||||||
|
ErrKeySet IRCMessageType = 467
|
||||||
|
ErrChannelIsFull IRCMessageType = 471
|
||||||
|
ErrUnknownMode IRCMessageType = 472
|
||||||
|
ErrInviteOnlyChan IRCMessageType = 473
|
||||||
|
ErrBannedFromChan IRCMessageType = 474
|
||||||
|
ErrBadChannelKey IRCMessageType = 475
|
||||||
|
ErrBadChanMask IRCMessageType = 476
|
||||||
|
ErrNoChanModes IRCMessageType = 477
|
||||||
|
ErrBanListFull IRCMessageType = 478
|
||||||
|
ErrNoPrivileges IRCMessageType = 481
|
||||||
|
ErrChanOpPrivsNeeded IRCMessageType = 482
|
||||||
|
ErrCantKillServer IRCMessageType = 483
|
||||||
|
ErrRestricted IRCMessageType = 484
|
||||||
|
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
||||||
|
ErrNoOperHost IRCMessageType = 491
|
||||||
|
|
||||||
|
ErrUmodeUnknownFlag IRCMessageType = 501
|
||||||
|
ErrUsersDoNotMatch IRCMessageType = 502
|
||||||
|
)
|
||||||
|
|
||||||
|
// names maps numeric codes to their standard IRC names.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var names = map[IRCMessageType]string{
|
||||||
|
RplWelcome: "RPL_WELCOME",
|
||||||
|
RplYourHost: "RPL_YOURHOST",
|
||||||
|
RplCreated: "RPL_CREATED",
|
||||||
|
RplMyInfo: "RPL_MYINFO",
|
||||||
|
RplBounce: "RPL_BOUNCE",
|
||||||
|
|
||||||
|
RplTraceLink: "RPL_TRACELINK",
|
||||||
|
RplTraceConnecting: "RPL_TRACECONNECTING",
|
||||||
|
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
||||||
|
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
||||||
|
RplTraceOperator: "RPL_TRACEOPERATOR",
|
||||||
|
RplTraceUser: "RPL_TRACEUSER",
|
||||||
|
RplTraceServer: "RPL_TRACESERVER",
|
||||||
|
RplTraceService: "RPL_TRACESERVICE",
|
||||||
|
RplTraceNewType: "RPL_TRACENEWTYPE",
|
||||||
|
RplTraceClass: "RPL_TRACECLASS",
|
||||||
|
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
||||||
|
RplStatsCommands: "RPL_STATSCOMMANDS",
|
||||||
|
RplStatsCLine: "RPL_STATSCLINE",
|
||||||
|
RplStatsNLine: "RPL_STATSNLINE",
|
||||||
|
RplStatsILine: "RPL_STATSILINE",
|
||||||
|
RplStatsKLine: "RPL_STATSKLINE",
|
||||||
|
RplStatsQLine: "RPL_STATSQLINE",
|
||||||
|
RplStatsYLine: "RPL_STATSYLINE",
|
||||||
|
RplEndOfStats: "RPL_ENDOFSTATS",
|
||||||
|
|
||||||
|
RplUmodeIs: "RPL_UMODEIS",
|
||||||
|
RplServList: "RPL_SERVLIST",
|
||||||
|
RplServListEnd: "RPL_SERVLISTEND",
|
||||||
|
RplStatsLLine: "RPL_STATSLLINE",
|
||||||
|
RplStatsUptime: "RPL_STATSUPTIME",
|
||||||
|
RplStatsOLine: "RPL_STATSOLINE",
|
||||||
|
RplStatsHLine: "RPL_STATSHLINE",
|
||||||
|
RplLuserClient: "RPL_LUSERCLIENT",
|
||||||
|
RplLuserOp: "RPL_LUSEROP",
|
||||||
|
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||||
|
|
||||||
|
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||||
|
RplLuserMe: "RPL_LUSERME",
|
||||||
|
RplAdminMe: "RPL_ADMINME",
|
||||||
|
RplAdminLoc1: "RPL_ADMINLOC1",
|
||||||
|
RplAdminLoc2: "RPL_ADMINLOC2",
|
||||||
|
RplAdminEmail: "RPL_ADMINEMAIL",
|
||||||
|
RplTraceLog: "RPL_TRACELOG",
|
||||||
|
RplTraceEnd: "RPL_TRACEEND",
|
||||||
|
RplTryAgain: "RPL_TRYAGAIN",
|
||||||
|
|
||||||
|
RplAway: "RPL_AWAY",
|
||||||
|
RplUserHost: "RPL_USERHOST",
|
||||||
|
RplIson: "RPL_ISON",
|
||||||
|
RplUnaway: "RPL_UNAWAY",
|
||||||
|
RplNowAway: "RPL_NOWAWAY",
|
||||||
|
RplWhoisUser: "RPL_WHOISUSER",
|
||||||
|
RplWhoisServer: "RPL_WHOISSERVER",
|
||||||
|
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||||
|
RplWhoWasUser: "RPL_WHOWASUSER",
|
||||||
|
RplEndOfWho: "RPL_ENDOFWHO",
|
||||||
|
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||||
|
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||||
|
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||||
|
RplListStart: "RPL_LISTSTART",
|
||||||
|
RplList: "RPL_LIST",
|
||||||
|
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||||
|
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||||
|
|
||||||
|
RplUniqOpIs: "RPL_UNIQOPIS",
|
||||||
|
RplCreationTime: "RPL_CREATIONTIME",
|
||||||
|
RplNoTopic: "RPL_NOTOPIC",
|
||||||
|
RplTopic: "RPL_TOPIC",
|
||||||
|
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||||
|
RplInviting: "RPL_INVITING",
|
||||||
|
RplSummoning: "RPL_SUMMONING",
|
||||||
|
RplInviteList: "RPL_INVITELIST",
|
||||||
|
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
||||||
|
RplExceptList: "RPL_EXCEPTLIST",
|
||||||
|
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
||||||
|
RplVersion: "RPL_VERSION",
|
||||||
|
RplWhoReply: "RPL_WHOREPLY",
|
||||||
|
RplNamReply: "RPL_NAMREPLY",
|
||||||
|
RplLinks: "RPL_LINKS",
|
||||||
|
RplEndOfLinks: "RPL_ENDOFLINKS",
|
||||||
|
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||||
|
RplBanList: "RPL_BANLIST",
|
||||||
|
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||||
|
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
||||||
|
RplInfo: "RPL_INFO",
|
||||||
|
RplMotd: "RPL_MOTD",
|
||||||
|
RplEndOfInfo: "RPL_ENDOFINFO",
|
||||||
|
RplMotdStart: "RPL_MOTDSTART",
|
||||||
|
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||||
|
RplYoureOper: "RPL_YOUREOPER",
|
||||||
|
RplRehashing: "RPL_REHASHING",
|
||||||
|
RplYoureService: "RPL_YOURESERVICE",
|
||||||
|
RplTime: "RPL_TIME",
|
||||||
|
RplUsersStart: "RPL_USERSSTART",
|
||||||
|
RplUsers: "RPL_USERS",
|
||||||
|
RplEndOfUsers: "RPL_ENDOFUSERS",
|
||||||
|
RplNoUsers: "RPL_NOUSERS",
|
||||||
|
|
||||||
|
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||||
|
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||||
|
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||||
|
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||||
|
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||||
|
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
||||||
|
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
||||||
|
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
||||||
|
ErrNoOrigin: "ERR_NOORIGIN",
|
||||||
|
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||||
|
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||||
|
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
||||||
|
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
||||||
|
ErrBadMask: "ERR_BADMASK",
|
||||||
|
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||||
|
ErrNoMotd: "ERR_NOMOTD",
|
||||||
|
ErrNoAdminInfo: "ERR_NOADMININFO",
|
||||||
|
ErrFileError: "ERR_FILEERROR",
|
||||||
|
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||||
|
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||||
|
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||||
|
ErrNickCollision: "ERR_NICKCOLLISION",
|
||||||
|
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
||||||
|
|
||||||
|
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||||
|
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||||
|
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
||||||
|
ErrNoLogin: "ERR_NOLOGIN",
|
||||||
|
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
||||||
|
ErrUsersDisabled: "ERR_USERSDISABLED",
|
||||||
|
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||||
|
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||||
|
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||||
|
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
||||||
|
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
||||||
|
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
||||||
|
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
||||||
|
ErrKeySet: "ERR_KEYSET",
|
||||||
|
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||||
|
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
||||||
|
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||||
|
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||||
|
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||||
|
ErrBadChanMask: "ERR_BADCHANMASK",
|
||||||
|
ErrNoChanModes: "ERR_NOCHANMODES",
|
||||||
|
ErrBanListFull: "ERR_BANLISTFULL",
|
||||||
|
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
||||||
|
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||||
|
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
||||||
|
ErrRestricted: "ERR_RESTRICTED",
|
||||||
|
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
||||||
|
ErrNoOperHost: "ERR_NOOPERHOST",
|
||||||
|
|
||||||
|
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
||||||
|
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the standard IRC name for a numeric code
|
||||||
|
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||||
|
// empty string if the code is unknown.
|
||||||
|
//
|
||||||
|
// Deprecated: Use IRCMessageType.Name() instead.
|
||||||
|
func Name(code IRCMessageType) string {
|
||||||
|
return names[code]
|
||||||
|
}
|
||||||
163
pkg/irc/numerics_test.go
Normal file
163
pkg/irc/numerics_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package irc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, "RPL_WELCOME"},
|
||||||
|
{irc.RplBounce, "RPL_BOUNCE"},
|
||||||
|
{irc.RplLuserOp, "RPL_LUSEROP"},
|
||||||
|
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
||||||
|
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.Name(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
||||||
|
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
||||||
|
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
||||||
|
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.String(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, "001"},
|
||||||
|
{irc.RplBounce, "005"},
|
||||||
|
{irc.RplLuserOp, "252"},
|
||||||
|
{irc.ErrCannotSendToChan, "404"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.Code(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, 1},
|
||||||
|
{irc.RplBounce, 5},
|
||||||
|
{irc.RplLuserOp, 252},
|
||||||
|
{irc.ErrCannotSendToChan, 404},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.Int(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromInt_Known(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want irc.IRCMessageType
|
||||||
|
}{
|
||||||
|
{1, irc.RplWelcome},
|
||||||
|
{5, irc.RplBounce},
|
||||||
|
{252, irc.RplLuserOp},
|
||||||
|
{404, irc.ErrCannotSendToChan},
|
||||||
|
{433, irc.ErrNicknameInUse},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
got, err := irc.FromInt(test.code)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != test.want {
|
||||||
|
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromInt_Unknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unknowns := []int{0, 999, 600, -1}
|
||||||
|
for _, code := range unknowns {
|
||||||
|
_, err := irc.FromInt(code)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("FromInt(%d) expected error, got nil", code)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
||||||
|
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownNumeric_Name(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unknown := irc.IRCMessageType(999)
|
||||||
|
if got := unknown.Name(); got != "" {
|
||||||
|
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownNumeric_String(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unknown := irc.IRCMessageType(999)
|
||||||
|
want := "UNKNOWN <999>"
|
||||||
|
|
||||||
|
if got := unknown.String(); got != want {
|
||||||
|
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeprecatedNameFunc(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
||||||
|
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
||||||
|
}
|
||||||
|
}
|
||||||
2
web/dist/app.js
vendored
2
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
13
web/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>NeoIRC</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
466
web/dist/style.css
vendored
466
web/dist/style.css
vendored
@@ -1,466 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0a0e14;
|
|
||||||
--bg-panel: #0d1117;
|
|
||||||
--bg-input: #0d1117;
|
|
||||||
--bg-tab: #161b22;
|
|
||||||
--bg-tab-active: #0d1117;
|
|
||||||
--bg-topic: #0d1117;
|
|
||||||
--text: #c9d1d9;
|
|
||||||
--text-dim: #6e7681;
|
|
||||||
--text-bright: #e6edf3;
|
|
||||||
--accent: #58a6ff;
|
|
||||||
--accent-dim: #1f6feb;
|
|
||||||
--border: #21262d;
|
|
||||||
--system: #7d8590;
|
|
||||||
--action: #d2a8ff;
|
|
||||||
--warn: #d29922;
|
|
||||||
--error: #f85149;
|
|
||||||
--unread: #f0883e;
|
|
||||||
--nick-brackets: #6e7681;
|
|
||||||
--timestamp: #484f58;
|
|
||||||
--input-bg: #161b22;
|
|
||||||
--prompt: #3fb950;
|
|
||||||
--tab-indicator: #58a6ff;
|
|
||||||
--user-list-bg: #0d1117;
|
|
||||||
--user-list-header: #484f58;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
|
||||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Login Screen
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.login-screen {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 360px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box h1 {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.8em;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box .motd {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-bright);
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box input:focus {
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: none;
|
|
||||||
color: var(--text-bright);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box button:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box .error {
|
|
||||||
color: var(--error);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
IRC App Layout
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.irc-app {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Tab Bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.tab-bar {
|
|
||||||
display: flex;
|
|
||||||
background: var(--bg-tab);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 32px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
flex: 1;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
gap: 4px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
color: var(--text-bright);
|
|
||||||
background: var(--bg-tab-active);
|
|
||||||
border-bottom: 2px solid var(--tab-indicator);
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.has-unread .tab-label {
|
|
||||||
color: var(--unread);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab .unread-count {
|
|
||||||
color: var(--unread);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-close {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-close:hover {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-area {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-nick {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-warn {
|
|
||||||
color: var(--warn);
|
|
||||||
animation: blink 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Topic Bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.topic-bar {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--bg-topic);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-text {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Main Content Area
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.main-area {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Messages Panel
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.messages-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 8px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Message Lines
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.message {
|
|
||||||
padding: 1px 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .timestamp {
|
|
||||||
color: var(--timestamp);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .nick {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .content {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* System messages (joins, parts, quits, etc.) */
|
|
||||||
.system-message {
|
|
||||||
color: var(--system);
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-message .system-text {
|
|
||||||
color: var(--system);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* /me action messages */
|
|
||||||
.action-message .action-text {
|
|
||||||
color: var(--action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
User List (Right Panel)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.user-list {
|
|
||||||
width: 160px;
|
|
||||||
background: var(--user-list-bg);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list-header {
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--user-list-header);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list-entries {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
flex: 1;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-entry {
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-entry:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-prefix {
|
|
||||||
color: var(--text-dim);
|
|
||||||
display: inline-block;
|
|
||||||
width: 1ch;
|
|
||||||
text-align: right;
|
|
||||||
margin-right: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-name {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Input Line (Bottom)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.input-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 8px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
color: var(--prompt);
|
|
||||||
font-size: 13px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-line input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-bright);
|
|
||||||
outline: none;
|
|
||||||
caret-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-line input::placeholder {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Responsive
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.user-list {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
web/src/app.jsx
101
web/src/app.jsx
@@ -8,6 +8,56 @@ const MEMBER_REFRESH_INTERVAL = 10000;
|
|||||||
const ACTION_PREFIX = "\x01ACTION ";
|
const ACTION_PREFIX = "\x01ACTION ";
|
||||||
const ACTION_SUFFIX = "\x01";
|
const ACTION_SUFFIX = "\x01";
|
||||||
|
|
||||||
|
// Hashcash proof-of-work helpers using Web Crypto API.
|
||||||
|
|
||||||
|
function checkLeadingZeros(hashBytes, bits) {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < hashBytes.length; i++) {
|
||||||
|
if (hashBytes[i] === 0) {
|
||||||
|
count += 8;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let b = hashBytes[i];
|
||||||
|
while ((b & 0x80) === 0) {
|
||||||
|
count++;
|
||||||
|
b <<= 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return count >= bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintHashcash(bits, resource) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const now = new Date();
|
||||||
|
const date =
|
||||||
|
String(now.getUTCFullYear()).slice(2) +
|
||||||
|
String(now.getUTCMonth() + 1).padStart(2, "0") +
|
||||||
|
String(now.getUTCDate()).padStart(2, "0");
|
||||||
|
const prefix = `1:${bits}:${date}:${resource}::`;
|
||||||
|
let nonce = Math.floor(Math.random() * 0x100000);
|
||||||
|
const batchSize = 1024;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const stamps = [];
|
||||||
|
const hashPromises = [];
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const stamp = prefix + (nonce + i).toString(16);
|
||||||
|
stamps.push(stamp);
|
||||||
|
hashPromises.push(
|
||||||
|
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hashes = await Promise.all(hashPromises);
|
||||||
|
for (let i = 0; i < hashes.length; i++) {
|
||||||
|
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
|
||||||
|
return stamps[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nonce += batchSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function api(path, opts = {}) {
|
function api(path, opts = {}) {
|
||||||
const token = localStorage.getItem("neoirc_token");
|
const token = localStorage.getItem("neoirc_token");
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -60,17 +110,21 @@ function LoginScreen({ onLogin }) {
|
|||||||
const [motd, setMotd] = useState("");
|
const [motd, setMotd] = useState("");
|
||||||
const [serverName, setServerName] = useState("NeoIRC");
|
const [serverName, setServerName] = useState("NeoIRC");
|
||||||
const inputRef = useRef();
|
const inputRef = useRef();
|
||||||
|
const hashcashBitsRef = useRef(0);
|
||||||
|
const hashcashResourceRef = useRef("neoirc");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api("/server")
|
api("/server")
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (s.name) setServerName(s.name);
|
if (s.name) setServerName(s.name);
|
||||||
if (s.motd) setMotd(s.motd);
|
if (s.motd) setMotd(s.motd);
|
||||||
|
hashcashBitsRef.current = s.hashcash_bits || 0;
|
||||||
|
if (s.name) hashcashResourceRef.current = s.name;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state")
|
api("/state?initChannelState=1")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -81,9 +135,22 @@ function LoginScreen({ onLogin }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
let hashcashStamp = "";
|
||||||
|
if (hashcashBitsRef.current > 0) {
|
||||||
|
setError("Computing proof-of-work...");
|
||||||
|
hashcashStamp = await mintHashcash(
|
||||||
|
hashcashBitsRef.current,
|
||||||
|
hashcashResourceRef.current,
|
||||||
|
);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
const reqBody = { nick: nick.trim() };
|
||||||
|
if (hashcashStamp) {
|
||||||
|
reqBody.pow_token = hashcashStamp;
|
||||||
|
}
|
||||||
const res = await api("/session", {
|
const res = await api("/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ nick: nick.trim() }),
|
body: JSON.stringify(reqBody),
|
||||||
});
|
});
|
||||||
localStorage.setItem("neoirc_token", res.token);
|
localStorage.setItem("neoirc_token", res.token);
|
||||||
onLogin(res.nick);
|
onLogin(res.nick);
|
||||||
@@ -333,7 +400,24 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -636,9 +720,13 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
// Request MOTD on resumed sessions (new sessions get
|
|
||||||
// it automatically from the server during creation).
|
|
||||||
if (isResumed) {
|
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 {
|
try {
|
||||||
await api("/messages", {
|
await api("/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -647,8 +735,11 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// MOTD is non-critical.
|
// MOTD is non-critical.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fresh session — join any previously saved channels.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user