Compare commits
3 Commits
main
...
8854b17ebc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8854b17ebc | ||
|
|
6fa46f4b75 | ||
|
|
a89393186f |
54
README.md
54
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.
|
||||||
- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`.
|
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48
|
||||||
Default is 30 days. Entries older than this are pruned.
|
hours. Entries older than this are pruned.
|
||||||
|
|
||||||
### Long-Polling
|
### Long-Polling
|
||||||
|
|
||||||
@@ -989,18 +989,23 @@ Create a new user session. This is the entry point for all clients.
|
|||||||
|
|
||||||
If the server requires hashcash proof-of-work (see
|
If the server requires hashcash proof-of-work (see
|
||||||
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
[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
|
valid stamp in the `X-Hashcash` request header. The required difficulty is
|
||||||
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||||
|
|
||||||
|
**Request Headers:**
|
||||||
|
|
||||||
|
| Header | Required | Description |
|
||||||
|
|--------------|----------|-------------|
|
||||||
|
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
{"nick": "alice"}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 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
|
||||||
@@ -1022,7 +1027,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
|||||||
| 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 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled |
|
||||||
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
| 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 |
|
||||||
|
|
||||||
@@ -1030,7 +1035,8 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
|||||||
```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","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
|
||||||
|
-d '{"nick":"alice"}' | jq -r .token)
|
||||||
echo $TOKEN
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1798,14 +1804,14 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
|||||||
|
|
||||||
### Data Lifecycle
|
### Data Lifecycle
|
||||||
|
|
||||||
- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE`
|
- **Messages**: Stored indefinitely in the current implementation. Rotation
|
||||||
(default 30 days).
|
per `MAX_HISTORY` is planned.
|
||||||
- **Client output queue entries**: Pruned automatically when older than
|
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
|
||||||
`QUEUE_MAX_AGE` (default 30 days).
|
planned.
|
||||||
- **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
|
||||||
30 days) — the server runs a background cleanup loop that parts idle users
|
24h) — 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1822,9 +1828,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) |
|
||||||
| `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. |
|
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
|
||||||
| `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. |
|
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
|
||||||
| `QUEUE_MAX_AGE` | 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. |
|
| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
|
||||||
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
|
| `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` |
|
||||||
@@ -1844,7 +1850,7 @@ 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=720h
|
SESSION_IDLE_TIMEOUT=24h
|
||||||
NEOIRC_HASHCASH_BITS=20
|
NEOIRC_HASHCASH_BITS=20
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2132,7 +2138,7 @@ account registration, no IP-based rate limits that punish shared networks.
|
|||||||
2. Client computes a hashcash stamp: find a counter value such that the
|
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
|
SHA-256 hash of the stamp string has the required number of leading zero
|
||||||
bits.
|
bits.
|
||||||
3. Client includes the stamp in the `pow_token` field of the JSON request body when creating
|
3. Client includes the stamp in the `X-Hashcash` request header when creating
|
||||||
a session: `POST /api/v1/session`.
|
a session: `POST /api/v1/session`.
|
||||||
4. Server validates the stamp:
|
4. Server validates the stamp:
|
||||||
- Version is `1`
|
- Version is `1`
|
||||||
@@ -2189,7 +2195,7 @@ Both the embedded web SPA and the CLI client automatically handle hashcash:
|
|||||||
|
|
||||||
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||||
2. If `hashcash_bits > 0`, compute a valid stamp
|
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`
|
3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session`
|
||||||
|
|
||||||
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
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`.
|
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||||
@@ -2253,8 +2259,8 @@ creating one session pays once and keeps their session.
|
|||||||
### Post-MVP (Planned)
|
### Post-MVP (Planned)
|
||||||
|
|
||||||
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||||
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
||||||
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||||
- [ ] **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)
|
||||||
|
|||||||
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/v5 v5.2.1
|
github.com/go-chi/chi v1.5.5
|
||||||
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/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
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/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -52,7 +52,7 @@ func (client *Client) CreateSession(
|
|||||||
// Fetch server info to check for hashcash requirement.
|
// Fetch server info to check for hashcash requirement.
|
||||||
info, err := client.GetServerInfo()
|
info, err := client.GetServerInfo()
|
||||||
|
|
||||||
var hashcashStamp string
|
var headers map[string]string
|
||||||
|
|
||||||
if err == nil && info.HashcashBits > 0 {
|
if err == nil && info.HashcashBits > 0 {
|
||||||
resource := info.Name
|
resource := info.Name
|
||||||
@@ -60,13 +60,17 @@ func (client *Client) CreateSession(
|
|||||||
resource = "neoirc"
|
resource = "neoirc"
|
||||||
}
|
}
|
||||||
|
|
||||||
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
stamp := MintHashcash(info.HashcashBits, resource)
|
||||||
|
headers = map[string]string{
|
||||||
|
"X-Hashcash": stamp,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := client.do(
|
data, err := client.doWithHeaders(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
&SessionRequest{Nick: nick},
|
||||||
|
headers,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -278,6 +282,16 @@ func (client *Client) GetServerInfo() (
|
|||||||
func (client *Client) do(
|
func (client *Client) do(
|
||||||
method, path string,
|
method, path string,
|
||||||
body any,
|
body any,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return client.doWithHeaders(
|
||||||
|
method, path, body, nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) doWithHeaders(
|
||||||
|
method, path string,
|
||||||
|
body any,
|
||||||
|
extraHeaders map[string]string,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
var bodyReader io.Reader
|
var bodyReader io.Reader
|
||||||
|
|
||||||
@@ -310,6 +324,10 @@ func (client *Client) do(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for key, val := range extraHeaders {
|
||||||
|
request.Header.Set(key, val)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.HTTPClient.Do(request)
|
resp, err := client.HTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ 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.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MessageMaxAge string
|
MaxHistory int
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
QueueMaxAge string
|
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
@@ -70,13 +69,12 @@ 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("MESSAGE_MAX_AGE", "720h")
|
viper.SetDefault("MAX_HISTORY", "10000")
|
||||||
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", "720h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
@@ -96,9 +94,8 @@ 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"),
|
||||||
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||||
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"),
|
||||||
|
|||||||
@@ -64,14 +64,12 @@ 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, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -139,14 +137,12 @@ 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, tokenHash, now, now)
|
clientUUID, sessionID, token, 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,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// 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,7 +3,6 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -11,7 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,14 +31,6 @@ 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"`
|
||||||
@@ -114,14 +105,12 @@ 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, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -154,8 +143,6 @@ 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
|
||||||
@@ -163,7 +150,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 = ?`,
|
||||||
tokenHash,
|
token,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
@@ -746,8 +733,8 @@ func scanMessages(
|
|||||||
code, _ := strconv.Atoi(msg.Command)
|
code, _ := strconv.Atoi(msg.Command)
|
||||||
msg.Code = code
|
msg.Code = code
|
||||||
|
|
||||||
if mt, err := irc.FromInt(code); err == nil {
|
if name := irc.Name(code); name != "" {
|
||||||
msg.Command = mt.Name()
|
msg.Command = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,160 +1096,3 @@ 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,7 +8,6 @@ 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
|
||||||
);
|
);
|
||||||
@@ -31,8 +30,6 @@ 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,9 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -71,10 +70,11 @@ 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.respondJSON(writer, request, map[string]any{
|
hdlr.respondError(
|
||||||
"error": "not registered",
|
writer, request,
|
||||||
"numeric": irc.ErrNotRegistered,
|
"unauthorized",
|
||||||
}, http.StatusUnauthorized)
|
http.StatusUnauthorized,
|
||||||
|
)
|
||||||
|
|
||||||
return 0, 0, "", false
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
@@ -144,9 +144,35 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
// Validate hashcash proof-of-work if configured.
|
||||||
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
|
stamp := request.Header.Get("X-Hashcash")
|
||||||
|
if stamp == "" {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"hashcash proof-of-work required",
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.hashcashVal.Validate(
|
||||||
|
stamp, hdlr.params.Config.HashcashBits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid hashcash stamp: "+err.Error(),
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type createRequest struct {
|
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
|
||||||
@@ -162,32 +188,6 @@ 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) {
|
||||||
@@ -226,7 +226,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -424,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 irc.IRCMessageType,
|
code int,
|
||||||
nick string,
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
command := code.Code()
|
command := fmt.Sprintf("%03d", code)
|
||||||
|
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -836,11 +836,6 @@ 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,
|
||||||
@@ -951,8 +946,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoRecipient, nick, []string{command},
|
irc.ErrNeedMoreParams, nick, []string{command},
|
||||||
"No recipient given",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -966,8 +961,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoTextToSend, nick, []string{command},
|
irc.ErrNeedMoreParams, nick, []string{command},
|
||||||
"No text to send",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -1000,7 +995,7 @@ func (hdlr *Handlers) respondIRCError(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
code irc.IRCMessageType,
|
code int,
|
||||||
nick string,
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
@@ -1054,8 +1049,8 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrCannotSendToChan, nick, []string{target},
|
irc.ErrNotOnChannel, nick, []string{target},
|
||||||
"Cannot send to channel",
|
"You're not on that channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1151,19 +1146,6 @@ 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)
|
||||||
@@ -1274,25 +1256,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
hdlr.deliverTopicNumerics(
|
chInfo, err := hdlr.params.Database.GetChannelByName(
|
||||||
ctx, clientID, sessionID, nick, channel, chID,
|
ctx, channel,
|
||||||
)
|
)
|
||||||
|
if err == nil {
|
||||||
|
_ = chInfo // chInfo is the ID; topic comes from DB.
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.deliverNamesNumerics(
|
// Get topic from channel info.
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -1314,39 +1285,14 @@ func (hdlr *Handlers) deliverTopicNumerics(
|
|||||||
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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// deliverNamesNumerics sends RPL_NAMREPLY and
|
// Get member list for NAMES reply.
|
||||||
// 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,
|
||||||
)
|
)
|
||||||
@@ -1369,6 +1315,8 @@ func (hdlr *Handlers) deliverNamesNumerics(
|
|||||||
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(
|
||||||
@@ -1506,7 +1454,7 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||||
@@ -1652,8 +1600,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
setErr := hdlr.params.Database.SetTopicMeta(
|
setErr := hdlr.params.Database.SetTopic(
|
||||||
request.Context(), channel, topic, nick,
|
request.Context(), channel, topic,
|
||||||
)
|
)
|
||||||
if setErr != nil {
|
if setErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -1680,25 +1628,6 @@ 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,
|
||||||
@@ -2088,11 +2017,6 @@ 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,
|
||||||
@@ -2510,95 +2434,3 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAway handles the AWAY command. An empty body
|
|
||||||
// clears the away status; a non-empty body sets it.
|
|
||||||
func (hdlr *Handlers) handleAway(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
|
|
||||||
awayMsg := ""
|
|
||||||
if len(lines) > 0 {
|
|
||||||
awayMsg = strings.Join(lines, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hdlr.params.Database.SetAway(
|
|
||||||
ctx, sessionID, awayMsg,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error("set away failed", "error", err)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if awayMsg == "" {
|
|
||||||
// 305 RPL_UNAWAY
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUnaway, nick, nil,
|
|
||||||
"You are no longer marked as being away",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 306 RPL_NOWAWAY
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplNowAway, nick, nil,
|
|
||||||
"You have been marked as being away",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
|
|
||||||
// time and signon time.
|
|
||||||
func (hdlr *Handlers) deliverWhoisIdle(
|
|
||||||
ctx context.Context,
|
|
||||||
clientID int64,
|
|
||||||
nick, queryNick string,
|
|
||||||
targetSID int64,
|
|
||||||
) {
|
|
||||||
lastSeen, lsErr := hdlr.params.Database.
|
|
||||||
GetSessionLastSeen(ctx, targetSID)
|
|
||||||
if lsErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
createdAt, caErr := hdlr.params.Database.
|
|
||||||
GetSessionCreatedAt(ctx, targetSID)
|
|
||||||
if caErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idleSeconds := int64(time.Since(lastSeen).Seconds())
|
|
||||||
if idleSeconds < 0 {
|
|
||||||
idleSeconds = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
signonUnix := strconv.FormatInt(
|
|
||||||
createdAt.Unix(), 10,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplWhoisIdle, nick,
|
|
||||||
[]string{
|
|
||||||
queryNick,
|
|
||||||
strconv.FormatInt(idleSeconds, 10),
|
|
||||||
signonUnix,
|
|
||||||
},
|
|
||||||
"seconds idle, signon time",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -811,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "412") {
|
if !findNumeric(msgs, "461") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOTEXTTOSEND (412), got %v",
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -835,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "411") {
|
if !findNumeric(msgs, "461") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NORECIPIENT (411), got %v",
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -870,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "404") {
|
if !findNumeric(msgs, "442") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -96,7 +94,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type Params struct {
|
|||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 24 * time.Hour
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
@@ -208,77 +208,4 @@ 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
package hashcash_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testBits = 2
|
|
||||||
|
|
||||||
// mintStampWithDate creates a valid hashcash stamp using
|
|
||||||
// the given date string.
|
|
||||||
func mintStampWithDate(
|
|
||||||
tb testing.TB,
|
|
||||||
bits int,
|
|
||||||
resource string,
|
|
||||||
date string,
|
|
||||||
) string {
|
|
||||||
tb.Helper()
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf(
|
|
||||||
"1:%d:%s:%s::", bits, date, resource,
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
|
||||||
counterVal, err := rand.Int(
|
|
||||||
rand.Reader, big.NewInt(1<<48),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
tb.Fatalf("random counter: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stamp := prefix + hex.EncodeToString(
|
|
||||||
counterVal.Bytes(),
|
|
||||||
)
|
|
||||||
hash := sha256.Sum256([]byte(stamp))
|
|
||||||
|
|
||||||
if hasLeadingZeroBits(hash[:], bits) {
|
|
||||||
return stamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLeadingZeroBits checks if hash has at least numBits
|
|
||||||
// leading zero bits. Duplicated here for test minting.
|
|
||||||
func hasLeadingZeroBits(
|
|
||||||
hash []byte,
|
|
||||||
numBits int,
|
|
||||||
) bool {
|
|
||||||
fullBytes := numBits / 8
|
|
||||||
remainBits := numBits % 8
|
|
||||||
|
|
||||||
for idx := range fullBytes {
|
|
||||||
if hash[idx] != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remainBits > 0 && fullBytes < len(hash) {
|
|
||||||
mask := byte(0xFF << (8 - remainBits))
|
|
||||||
|
|
||||||
if hash[fullBytes]&mask != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func todayDate() string {
|
|
||||||
return time.Now().UTC().Format("060102")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMintAndValidate(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("valid stamp rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReplayDetection(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first use failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("replay not detected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResourceMismatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("correct-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "wrong-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected resource mismatch error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidStampFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
|
|
||||||
err := validator.Validate(
|
|
||||||
"not:a:valid:stamp", testBits,
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for bad format")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBadVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := fmt.Sprintf(
|
|
||||||
"2:%d:%s:%s::abc123",
|
|
||||||
testBits, todayDate(), "test-resource",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected bad version error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInsufficientDifficulty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
// Claimed bits=1, but we require testBits=2.
|
|
||||||
stamp := fmt.Sprintf(
|
|
||||||
"1:1:%s:%s::counter",
|
|
||||||
todayDate(), "test-resource",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected insufficient bits error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpiredStamp(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
oldDate := time.Now().Add(-72 * time.Hour).
|
|
||||||
UTC().Format("060102")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", oldDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected expired stamp error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestZeroBitsSkipsValidation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
|
|
||||||
err := validator.Validate("garbage", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zero bits should skip: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLongDateFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
longDate := time.Now().UTC().Format("060102150405")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", longDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("long date stamp rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBadDateFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := fmt.Sprintf(
|
|
||||||
"1:%d:BADDATE:%s::counter",
|
|
||||||
testBits, "test-resource",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected bad date error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipleUniqueStamps(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
|
|
||||||
for range 5 {
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unique stamp rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHigherBitsStillValid(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Mint with bits=4 but validate requiring only 2.
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, 4, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"higher-difficulty stamp rejected: %v",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ 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"
|
||||||
150
internal/irc/numerics.go
Normal file
150
internal/irc/numerics.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Package irc provides constants and utilities for the
|
||||||
|
// IRC protocol, including numeric reply codes from
|
||||||
|
// RFC 1459 and RFC 2812, and standard command names.
|
||||||
|
package irc
|
||||||
|
|
||||||
|
// Connection registration replies (001-005).
|
||||||
|
const (
|
||||||
|
RplWelcome = 1
|
||||||
|
RplYourHost = 2
|
||||||
|
RplCreated = 3
|
||||||
|
RplMyInfo = 4
|
||||||
|
RplIsupport = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command responses (200-399).
|
||||||
|
const (
|
||||||
|
RplUmodeIs = 221
|
||||||
|
RplLuserClient = 251
|
||||||
|
RplLuserOp = 252
|
||||||
|
RplLuserUnknown = 253
|
||||||
|
RplLuserChannels = 254
|
||||||
|
RplLuserMe = 255
|
||||||
|
RplAway = 301
|
||||||
|
RplUserHost = 302
|
||||||
|
RplIson = 303
|
||||||
|
RplUnaway = 305
|
||||||
|
RplNowAway = 306
|
||||||
|
RplWhoisUser = 311
|
||||||
|
RplWhoisServer = 312
|
||||||
|
RplWhoisOperator = 313
|
||||||
|
RplEndOfWho = 315
|
||||||
|
RplWhoisIdle = 317
|
||||||
|
RplEndOfWhois = 318
|
||||||
|
RplWhoisChannels = 319
|
||||||
|
RplList = 322
|
||||||
|
RplListEnd = 323
|
||||||
|
RplChannelModeIs = 324
|
||||||
|
RplCreationTime = 329
|
||||||
|
RplNoTopic = 331
|
||||||
|
RplTopic = 332
|
||||||
|
RplTopicWhoTime = 333
|
||||||
|
RplInviting = 341
|
||||||
|
RplWhoReply = 352
|
||||||
|
RplNamReply = 353
|
||||||
|
RplEndOfNames = 366
|
||||||
|
RplBanList = 367
|
||||||
|
RplEndOfBanList = 368
|
||||||
|
RplMotd = 372
|
||||||
|
RplMotdStart = 375
|
||||||
|
RplEndOfMotd = 376
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error replies (400-599).
|
||||||
|
const (
|
||||||
|
ErrNoSuchNick = 401
|
||||||
|
ErrNoSuchServer = 402
|
||||||
|
ErrNoSuchChannel = 403
|
||||||
|
ErrCannotSendToChan = 404
|
||||||
|
ErrTooManyChannels = 405
|
||||||
|
ErrNoRecipient = 411
|
||||||
|
ErrNoTextToSend = 412
|
||||||
|
ErrUnknownCommand = 421
|
||||||
|
ErrNoNicknameGiven = 431
|
||||||
|
ErrErroneusNickname = 432
|
||||||
|
ErrNicknameInUse = 433
|
||||||
|
ErrUserNotInChannel = 441
|
||||||
|
ErrNotOnChannel = 442
|
||||||
|
ErrNotRegistered = 451
|
||||||
|
ErrNeedMoreParams = 461
|
||||||
|
ErrAlreadyRegistered = 462
|
||||||
|
ErrChannelIsFull = 471
|
||||||
|
ErrInviteOnlyChan = 473
|
||||||
|
ErrBannedFromChan = 474
|
||||||
|
ErrBadChannelKey = 475
|
||||||
|
ErrChanOpPrivsNeeded = 482
|
||||||
|
)
|
||||||
|
|
||||||
|
// names maps numeric codes to their standard IRC names.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var names = map[int]string{
|
||||||
|
RplWelcome: "RPL_WELCOME",
|
||||||
|
RplYourHost: "RPL_YOURHOST",
|
||||||
|
RplCreated: "RPL_CREATED",
|
||||||
|
RplMyInfo: "RPL_MYINFO",
|
||||||
|
RplIsupport: "RPL_ISUPPORT",
|
||||||
|
RplUmodeIs: "RPL_UMODEIS",
|
||||||
|
RplLuserClient: "RPL_LUSERCLIENT",
|
||||||
|
RplLuserOp: "RPL_LUSEROP",
|
||||||
|
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||||
|
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||||
|
RplLuserMe: "RPL_LUSERME",
|
||||||
|
RplAway: "RPL_AWAY",
|
||||||
|
RplUserHost: "RPL_USERHOST",
|
||||||
|
RplIson: "RPL_ISON",
|
||||||
|
RplUnaway: "RPL_UNAWAY",
|
||||||
|
RplNowAway: "RPL_NOWAWAY",
|
||||||
|
RplWhoisUser: "RPL_WHOISUSER",
|
||||||
|
RplWhoisServer: "RPL_WHOISSERVER",
|
||||||
|
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||||
|
RplEndOfWho: "RPL_ENDOFWHO",
|
||||||
|
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||||
|
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||||
|
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||||
|
RplList: "RPL_LIST",
|
||||||
|
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||||
|
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||||
|
RplCreationTime: "RPL_CREATIONTIME",
|
||||||
|
RplNoTopic: "RPL_NOTOPIC",
|
||||||
|
RplTopic: "RPL_TOPIC",
|
||||||
|
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||||
|
RplInviting: "RPL_INVITING",
|
||||||
|
RplWhoReply: "RPL_WHOREPLY",
|
||||||
|
RplNamReply: "RPL_NAMREPLY",
|
||||||
|
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||||
|
RplBanList: "RPL_BANLIST",
|
||||||
|
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||||
|
RplMotd: "RPL_MOTD",
|
||||||
|
RplMotdStart: "RPL_MOTDSTART",
|
||||||
|
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||||
|
|
||||||
|
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||||
|
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||||
|
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||||
|
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||||
|
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||||
|
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||||
|
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||||
|
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||||
|
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||||
|
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||||
|
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||||
|
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||||
|
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||||
|
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||||
|
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||||
|
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||||
|
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||||
|
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||||
|
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||||
|
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||||
|
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the standard IRC name for a numeric code
|
||||||
|
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||||
|
// empty string if the code is unknown.
|
||||||
|
func Name(code int) string {
|
||||||
|
return names[code]
|
||||||
|
}
|
||||||
@@ -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/v5/middleware"
|
chimw "github.com/go-chi/chi/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,6 +142,20 @@ 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
|
||||||
|
|||||||
@@ -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/v5"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/v5"
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IRCMessageType represents an IRC numeric reply or error code.
|
|
||||||
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for this numeric code
|
|
||||||
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
|
||||||
// Returns an empty string if the code is unknown.
|
|
||||||
func (t IRCMessageType) Name() string {
|
|
||||||
return names[t]
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the name and numeric code in angle brackets
|
|
||||||
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
|
||||||
// If the code is unknown, returns "UNKNOWN <NNN>".
|
|
||||||
func (t IRCMessageType) String() string {
|
|
||||||
n := names[t]
|
|
||||||
if n == "" {
|
|
||||||
n = "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s <%03d>", n, int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code returns the three-digit zero-padded string representation
|
|
||||||
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
|
||||||
func (t IRCMessageType) Code() string {
|
|
||||||
return fmt.Sprintf("%03d", int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Int returns the bare integer value of the numeric code.
|
|
||||||
func (t IRCMessageType) Int() int {
|
|
||||||
return int(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
|
||||||
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
|
||||||
|
|
||||||
// FromInt converts an integer to an IRCMessageType, returning an error
|
|
||||||
// if the numeric code is not a known IRC reply or error code.
|
|
||||||
func FromInt(n int) (IRCMessageType, error) {
|
|
||||||
t := IRCMessageType(n)
|
|
||||||
if _, ok := names[t]; !ok {
|
|
||||||
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome IRCMessageType = 1
|
|
||||||
RplYourHost IRCMessageType = 2
|
|
||||||
RplCreated IRCMessageType = 3
|
|
||||||
RplMyInfo IRCMessageType = 4
|
|
||||||
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
|
||||||
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
// RFC 2812 trace/stats/links replies (200-219).
|
|
||||||
RplTraceLink IRCMessageType = 200
|
|
||||||
RplTraceConnecting IRCMessageType = 201
|
|
||||||
RplTraceHandshake IRCMessageType = 202
|
|
||||||
RplTraceUnknown IRCMessageType = 203
|
|
||||||
RplTraceOperator IRCMessageType = 204
|
|
||||||
RplTraceUser IRCMessageType = 205
|
|
||||||
RplTraceServer IRCMessageType = 206
|
|
||||||
RplTraceService IRCMessageType = 207
|
|
||||||
RplTraceNewType IRCMessageType = 208
|
|
||||||
RplTraceClass IRCMessageType = 209
|
|
||||||
RplStatsLinkInfo IRCMessageType = 211
|
|
||||||
RplStatsCommands IRCMessageType = 212
|
|
||||||
RplStatsCLine IRCMessageType = 213
|
|
||||||
RplStatsNLine IRCMessageType = 214
|
|
||||||
RplStatsILine IRCMessageType = 215
|
|
||||||
RplStatsKLine IRCMessageType = 216
|
|
||||||
RplStatsQLine IRCMessageType = 217
|
|
||||||
RplStatsYLine IRCMessageType = 218
|
|
||||||
RplEndOfStats IRCMessageType = 219
|
|
||||||
|
|
||||||
RplUmodeIs IRCMessageType = 221
|
|
||||||
RplServList IRCMessageType = 234
|
|
||||||
RplServListEnd IRCMessageType = 235
|
|
||||||
RplStatsLLine IRCMessageType = 241
|
|
||||||
RplStatsUptime IRCMessageType = 242
|
|
||||||
RplStatsOLine IRCMessageType = 243
|
|
||||||
RplStatsHLine IRCMessageType = 244
|
|
||||||
RplLuserClient IRCMessageType = 251
|
|
||||||
RplLuserOp IRCMessageType = 252
|
|
||||||
RplLuserUnknown IRCMessageType = 253
|
|
||||||
|
|
||||||
RplLuserChannels IRCMessageType = 254
|
|
||||||
RplLuserMe IRCMessageType = 255
|
|
||||||
RplAdminMe IRCMessageType = 256
|
|
||||||
RplAdminLoc1 IRCMessageType = 257
|
|
||||||
RplAdminLoc2 IRCMessageType = 258
|
|
||||||
RplAdminEmail IRCMessageType = 259
|
|
||||||
RplTraceLog IRCMessageType = 261
|
|
||||||
RplTraceEnd IRCMessageType = 262
|
|
||||||
RplTryAgain IRCMessageType = 263
|
|
||||||
|
|
||||||
RplAway IRCMessageType = 301
|
|
||||||
RplUserHost IRCMessageType = 302
|
|
||||||
RplIson IRCMessageType = 303
|
|
||||||
RplUnaway IRCMessageType = 305
|
|
||||||
RplNowAway IRCMessageType = 306
|
|
||||||
RplWhoisUser IRCMessageType = 311
|
|
||||||
RplWhoisServer IRCMessageType = 312
|
|
||||||
RplWhoisOperator IRCMessageType = 313
|
|
||||||
RplWhoWasUser IRCMessageType = 314
|
|
||||||
RplEndOfWho IRCMessageType = 315
|
|
||||||
RplWhoisIdle IRCMessageType = 317
|
|
||||||
RplEndOfWhois IRCMessageType = 318
|
|
||||||
RplWhoisChannels IRCMessageType = 319
|
|
||||||
RplListStart IRCMessageType = 321
|
|
||||||
RplList IRCMessageType = 322
|
|
||||||
RplListEnd IRCMessageType = 323
|
|
||||||
RplChannelModeIs IRCMessageType = 324
|
|
||||||
|
|
||||||
RplUniqOpIs IRCMessageType = 325
|
|
||||||
RplCreationTime IRCMessageType = 329
|
|
||||||
RplNoTopic IRCMessageType = 331
|
|
||||||
RplTopic IRCMessageType = 332
|
|
||||||
RplTopicWhoTime IRCMessageType = 333
|
|
||||||
RplInviting IRCMessageType = 341
|
|
||||||
RplSummoning IRCMessageType = 342
|
|
||||||
RplInviteList IRCMessageType = 346
|
|
||||||
RplEndOfInviteList IRCMessageType = 347
|
|
||||||
RplExceptList IRCMessageType = 348
|
|
||||||
RplEndOfExceptList IRCMessageType = 349
|
|
||||||
RplVersion IRCMessageType = 351
|
|
||||||
RplWhoReply IRCMessageType = 352
|
|
||||||
RplNamReply IRCMessageType = 353
|
|
||||||
RplLinks IRCMessageType = 364
|
|
||||||
RplEndOfLinks IRCMessageType = 365
|
|
||||||
RplEndOfNames IRCMessageType = 366
|
|
||||||
RplBanList IRCMessageType = 367
|
|
||||||
RplEndOfBanList IRCMessageType = 368
|
|
||||||
RplEndOfWhowas IRCMessageType = 369
|
|
||||||
RplInfo IRCMessageType = 371
|
|
||||||
RplMotd IRCMessageType = 372
|
|
||||||
RplEndOfInfo IRCMessageType = 374
|
|
||||||
RplMotdStart IRCMessageType = 375
|
|
||||||
RplEndOfMotd IRCMessageType = 376
|
|
||||||
RplYoureOper IRCMessageType = 381
|
|
||||||
RplRehashing IRCMessageType = 382
|
|
||||||
RplYoureService IRCMessageType = 383
|
|
||||||
RplTime IRCMessageType = 391
|
|
||||||
RplUsersStart IRCMessageType = 392
|
|
||||||
RplUsers IRCMessageType = 393
|
|
||||||
RplEndOfUsers IRCMessageType = 394
|
|
||||||
RplNoUsers IRCMessageType = 395
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick IRCMessageType = 401
|
|
||||||
ErrNoSuchServer IRCMessageType = 402
|
|
||||||
ErrNoSuchChannel IRCMessageType = 403
|
|
||||||
ErrCannotSendToChan IRCMessageType = 404
|
|
||||||
ErrTooManyChannels IRCMessageType = 405
|
|
||||||
ErrWasNoSuchNick IRCMessageType = 406
|
|
||||||
ErrTooManyTargets IRCMessageType = 407
|
|
||||||
ErrNoSuchService IRCMessageType = 408
|
|
||||||
ErrNoOrigin IRCMessageType = 409
|
|
||||||
ErrNoRecipient IRCMessageType = 411
|
|
||||||
ErrNoTextToSend IRCMessageType = 412
|
|
||||||
ErrNoTopLevel IRCMessageType = 413
|
|
||||||
ErrWildTopLevel IRCMessageType = 414
|
|
||||||
ErrBadMask IRCMessageType = 415
|
|
||||||
ErrUnknownCommand IRCMessageType = 421
|
|
||||||
ErrNoMotd IRCMessageType = 422
|
|
||||||
ErrNoAdminInfo IRCMessageType = 423
|
|
||||||
ErrFileError IRCMessageType = 424
|
|
||||||
ErrNoNicknameGiven IRCMessageType = 431
|
|
||||||
ErrErroneusNickname IRCMessageType = 432
|
|
||||||
ErrNicknameInUse IRCMessageType = 433
|
|
||||||
ErrNickCollision IRCMessageType = 436
|
|
||||||
ErrUnavailResource IRCMessageType = 437
|
|
||||||
|
|
||||||
ErrUserNotInChannel IRCMessageType = 441
|
|
||||||
ErrNotOnChannel IRCMessageType = 442
|
|
||||||
ErrUserOnChannel IRCMessageType = 443
|
|
||||||
ErrNoLogin IRCMessageType = 444
|
|
||||||
ErrSummonDisabled IRCMessageType = 445
|
|
||||||
ErrUsersDisabled IRCMessageType = 446
|
|
||||||
ErrNotRegistered IRCMessageType = 451
|
|
||||||
ErrNeedMoreParams IRCMessageType = 461
|
|
||||||
ErrAlreadyRegistered IRCMessageType = 462
|
|
||||||
ErrNoPermForHost IRCMessageType = 463
|
|
||||||
ErrPasswdMismatch IRCMessageType = 464
|
|
||||||
ErrYoureBannedCreep IRCMessageType = 465
|
|
||||||
ErrYouWillBeBanned IRCMessageType = 466
|
|
||||||
ErrKeySet IRCMessageType = 467
|
|
||||||
ErrChannelIsFull IRCMessageType = 471
|
|
||||||
ErrUnknownMode IRCMessageType = 472
|
|
||||||
ErrInviteOnlyChan IRCMessageType = 473
|
|
||||||
ErrBannedFromChan IRCMessageType = 474
|
|
||||||
ErrBadChannelKey IRCMessageType = 475
|
|
||||||
ErrBadChanMask IRCMessageType = 476
|
|
||||||
ErrNoChanModes IRCMessageType = 477
|
|
||||||
ErrBanListFull IRCMessageType = 478
|
|
||||||
ErrNoPrivileges IRCMessageType = 481
|
|
||||||
ErrChanOpPrivsNeeded IRCMessageType = 482
|
|
||||||
ErrCantKillServer IRCMessageType = 483
|
|
||||||
ErrRestricted IRCMessageType = 484
|
|
||||||
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
|
||||||
ErrNoOperHost IRCMessageType = 491
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag IRCMessageType = 501
|
|
||||||
ErrUsersDoNotMatch IRCMessageType = 502
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[IRCMessageType]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplBounce: "RPL_BOUNCE",
|
|
||||||
|
|
||||||
RplTraceLink: "RPL_TRACELINK",
|
|
||||||
RplTraceConnecting: "RPL_TRACECONNECTING",
|
|
||||||
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
|
||||||
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
|
||||||
RplTraceOperator: "RPL_TRACEOPERATOR",
|
|
||||||
RplTraceUser: "RPL_TRACEUSER",
|
|
||||||
RplTraceServer: "RPL_TRACESERVER",
|
|
||||||
RplTraceService: "RPL_TRACESERVICE",
|
|
||||||
RplTraceNewType: "RPL_TRACENEWTYPE",
|
|
||||||
RplTraceClass: "RPL_TRACECLASS",
|
|
||||||
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
|
||||||
RplStatsCommands: "RPL_STATSCOMMANDS",
|
|
||||||
RplStatsCLine: "RPL_STATSCLINE",
|
|
||||||
RplStatsNLine: "RPL_STATSNLINE",
|
|
||||||
RplStatsILine: "RPL_STATSILINE",
|
|
||||||
RplStatsKLine: "RPL_STATSKLINE",
|
|
||||||
RplStatsQLine: "RPL_STATSQLINE",
|
|
||||||
RplStatsYLine: "RPL_STATSYLINE",
|
|
||||||
RplEndOfStats: "RPL_ENDOFSTATS",
|
|
||||||
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplServList: "RPL_SERVLIST",
|
|
||||||
RplServListEnd: "RPL_SERVLISTEND",
|
|
||||||
RplStatsLLine: "RPL_STATSLLINE",
|
|
||||||
RplStatsUptime: "RPL_STATSUPTIME",
|
|
||||||
RplStatsOLine: "RPL_STATSOLINE",
|
|
||||||
RplStatsHLine: "RPL_STATSHLINE",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAdminMe: "RPL_ADMINME",
|
|
||||||
RplAdminLoc1: "RPL_ADMINLOC1",
|
|
||||||
RplAdminLoc2: "RPL_ADMINLOC2",
|
|
||||||
RplAdminEmail: "RPL_ADMINEMAIL",
|
|
||||||
RplTraceLog: "RPL_TRACELOG",
|
|
||||||
RplTraceEnd: "RPL_TRACEEND",
|
|
||||||
RplTryAgain: "RPL_TRYAGAIN",
|
|
||||||
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplWhoWasUser: "RPL_WHOWASUSER",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplListStart: "RPL_LISTSTART",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
|
|
||||||
RplUniqOpIs: "RPL_UNIQOPIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplSummoning: "RPL_SUMMONING",
|
|
||||||
RplInviteList: "RPL_INVITELIST",
|
|
||||||
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
|
||||||
RplExceptList: "RPL_EXCEPTLIST",
|
|
||||||
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
|
||||||
RplVersion: "RPL_VERSION",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplLinks: "RPL_LINKS",
|
|
||||||
RplEndOfLinks: "RPL_ENDOFLINKS",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
|
||||||
RplInfo: "RPL_INFO",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplEndOfInfo: "RPL_ENDOFINFO",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
RplYoureOper: "RPL_YOUREOPER",
|
|
||||||
RplRehashing: "RPL_REHASHING",
|
|
||||||
RplYoureService: "RPL_YOURESERVICE",
|
|
||||||
RplTime: "RPL_TIME",
|
|
||||||
RplUsersStart: "RPL_USERSSTART",
|
|
||||||
RplUsers: "RPL_USERS",
|
|
||||||
RplEndOfUsers: "RPL_ENDOFUSERS",
|
|
||||||
RplNoUsers: "RPL_NOUSERS",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
|
||||||
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
|
||||||
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
|
||||||
ErrNoOrigin: "ERR_NOORIGIN",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
|
||||||
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
|
||||||
ErrBadMask: "ERR_BADMASK",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoMotd: "ERR_NOMOTD",
|
|
||||||
ErrNoAdminInfo: "ERR_NOADMININFO",
|
|
||||||
ErrFileError: "ERR_FILEERROR",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrNickCollision: "ERR_NICKCOLLISION",
|
|
||||||
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
|
||||||
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
|
||||||
ErrNoLogin: "ERR_NOLOGIN",
|
|
||||||
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
|
||||||
ErrUsersDisabled: "ERR_USERSDISABLED",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
|
||||||
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
|
||||||
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
|
||||||
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
|
||||||
ErrKeySet: "ERR_KEYSET",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrBadChanMask: "ERR_BADCHANMASK",
|
|
||||||
ErrNoChanModes: "ERR_NOCHANMODES",
|
|
||||||
ErrBanListFull: "ERR_BANLISTFULL",
|
|
||||||
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
|
||||||
ErrRestricted: "ERR_RESTRICTED",
|
|
||||||
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
|
||||||
ErrNoOperHost: "ERR_NOOPERHOST",
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
|
||||||
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
//
|
|
||||||
// Deprecated: Use IRCMessageType.Name() instead.
|
|
||||||
func Name(code IRCMessageType) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package irc_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
|
||||||
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Name(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestString(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.String(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "001"},
|
|
||||||
{irc.RplBounce, "005"},
|
|
||||||
{irc.RplLuserOp, "252"},
|
|
||||||
{irc.ErrCannotSendToChan, "404"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Code(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, 1},
|
|
||||||
{irc.RplBounce, 5},
|
|
||||||
{irc.RplLuserOp, 252},
|
|
||||||
{irc.ErrCannotSendToChan, 404},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Int(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Known(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
code int
|
|
||||||
want irc.IRCMessageType
|
|
||||||
}{
|
|
||||||
{1, irc.RplWelcome},
|
|
||||||
{5, irc.RplBounce},
|
|
||||||
{252, irc.RplLuserOp},
|
|
||||||
{404, irc.ErrCannotSendToChan},
|
|
||||||
{433, irc.ErrNicknameInUse},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
got, err := irc.FromInt(test.code)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != test.want {
|
|
||||||
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Unknown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknowns := []int{0, 999, 600, -1}
|
|
||||||
for _, code := range unknowns {
|
|
||||||
_, err := irc.FromInt(code)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("FromInt(%d) expected error, got nil", code)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
|
||||||
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_Name(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
if got := unknown.Name(); got != "" {
|
|
||||||
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_String(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
want := "UNKNOWN <999>"
|
|
||||||
|
|
||||||
if got := unknown.String(); got != want {
|
|
||||||
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeprecatedNameFunc(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
|
||||||
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -135,22 +135,20 @@ function LoginScreen({ onLogin }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
let hashcashStamp = "";
|
const extraHeaders = {};
|
||||||
if (hashcashBitsRef.current > 0) {
|
if (hashcashBitsRef.current > 0) {
|
||||||
setError("Computing proof-of-work...");
|
setError("Computing proof-of-work...");
|
||||||
hashcashStamp = await mintHashcash(
|
const stamp = await mintHashcash(
|
||||||
hashcashBitsRef.current,
|
hashcashBitsRef.current,
|
||||||
hashcashResourceRef.current,
|
hashcashResourceRef.current,
|
||||||
);
|
);
|
||||||
|
extraHeaders["X-Hashcash"] = stamp;
|
||||||
setError("");
|
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(reqBody),
|
body: JSON.stringify({ nick: nick.trim() }),
|
||||||
|
headers: extraHeaders,
|
||||||
});
|
});
|
||||||
localStorage.setItem("neoirc_token", res.token);
|
localStorage.setItem("neoirc_token", res.token);
|
||||||
onLogin(res.nick);
|
onLogin(res.nick);
|
||||||
|
|||||||
Reference in New Issue
Block a user