Compare commits
5 Commits
feature/ch
...
46399de6dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46399de6dc | ||
|
|
82bff502fe | ||
|
|
a586e92dad | ||
|
|
50e7a93287 | ||
|
|
fe937b5ebb |
81
README.md
81
README.md
@@ -461,7 +461,7 @@ the same JSON envelope:
|
||||
| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). |
|
||||
| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** |
|
||||
| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
|
||||
|
||||
**Important invariants:**
|
||||
|
||||
@@ -950,14 +950,13 @@ carries IRC-style parameters (e.g., channel name, target nick).
|
||||
|
||||
Inspired by IRC, simplified:
|
||||
|
||||
| Mode | Name | Meaning |
|
||||
|------|----------------|---------|
|
||||
| `+i` | Invite-only | Only invited users can join |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||
| `+s` | Secret | Channel hidden from LIST response |
|
||||
| `+t` | Topic lock | Only operators can change the topic |
|
||||
| `+n` | No external | Only channel members can send messages to the channel |
|
||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
|
||||
| Mode | Name | Meaning |
|
||||
|------|--------------|---------|
|
||||
| `+i` | Invite-only | Only invited users can join |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||
| `+s` | Secret | Channel hidden from LIST response |
|
||||
| `+t` | Topic lock | Only operators can change the topic |
|
||||
| `+n` | No external | Only channel members can send messages to the channel |
|
||||
|
||||
**User channel modes (set per-user per-channel):**
|
||||
|
||||
@@ -968,56 +967,6 @@ Inspired by IRC, simplified:
|
||||
|
||||
**Status:** Channel modes are defined but not yet enforced. The `modes` column
|
||||
exists in the channels table but the server does not check modes on actions.
|
||||
Exception: `+H` (hashcash) is fully enforced — see below.
|
||||
|
||||
### Per-Channel Hashcash (Anti-Spam)
|
||||
|
||||
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||||
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||||
compute a proof-of-work stamp bound to the specific channel and message before
|
||||
sending.
|
||||
|
||||
**Setting the requirement:**
|
||||
|
||||
```
|
||||
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
|
||||
MODE #channel -H — disable hashcash requirement
|
||||
```
|
||||
|
||||
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
|
||||
|
||||
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
|
||||
- `YYMMDD` — current date (prevents old token reuse)
|
||||
- `channel` — channel name (prevents cross-channel reuse)
|
||||
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
|
||||
- `counter` — hex nonce
|
||||
|
||||
**Sending a message to a hashcash-protected channel:**
|
||||
|
||||
Include the hashcash stamp in the `meta` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "PRIVMSG",
|
||||
"to": "#general",
|
||||
"body": ["hello world"],
|
||||
"meta": {
|
||||
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server validation:** The server checks that the stamp is well-formed, meets
|
||||
the required difficulty, is bound to the correct channel and message body, has a
|
||||
recent date, and has not been previously used. Spent stamps are cached for 1
|
||||
year to prevent replay attacks.
|
||||
|
||||
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
||||
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
||||
descriptive reason.
|
||||
|
||||
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
|
||||
to compute stamps. Higher bit counts take exponentially longer to compute.
|
||||
|
||||
---
|
||||
|
||||
@@ -1040,18 +989,18 @@ Create a new user session. This is the entry point for all clients.
|
||||
|
||||
If the server requires hashcash proof-of-work (see
|
||||
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
||||
valid stamp in the `pow_token` field of the JSON request body. The required
|
||||
valid stamp in the `hashcash` field of the JSON request body. The required
|
||||
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||
{"nick": "alice", "hashcash": "1:20:260310:neoirc::3a2f1"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|-------------|-------------|
|
||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||
| `hashcash` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
@@ -1073,7 +1022,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled |
|
||||
| 402 | `hashcash proof-of-work required` | Missing `hashcash` field in request body when hashcash is enabled |
|
||||
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||
| 409 | `nick already taken` | Another active session holds this nick |
|
||||
|
||||
@@ -1081,7 +1030,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||
-d '{"nick":"alice","hashcash":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
```
|
||||
|
||||
@@ -2183,7 +2132,7 @@ account registration, no IP-based rate limits that punish shared networks.
|
||||
2. Client computes a hashcash stamp: find a counter value such that the
|
||||
SHA-256 hash of the stamp string has the required number of leading zero
|
||||
bits.
|
||||
3. Client includes the stamp in the `pow_token` field of the JSON request body when creating
|
||||
3. Client includes the stamp in the `hashcash` field of the JSON request body when creating
|
||||
a session: `POST /api/v1/session`.
|
||||
4. Server validates the stamp:
|
||||
- Version is `1`
|
||||
@@ -2240,7 +2189,7 @@ Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||
|
||||
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||
2. If `hashcash_bits > 0`, compute a valid stamp
|
||||
3. Include the stamp in the `pow_token` field of the JSON body on `POST /api/v1/session`
|
||||
3. Include the stamp in the `hashcash` field of the JSON body on `POST /api/v1/session`
|
||||
|
||||
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
||||
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||
|
||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/getsentry/sentry-go v0.42.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||
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/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,23 +37,6 @@ func MintHashcash(bits int, resource string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// MintChannelHashcash computes a hashcash stamp bound to
|
||||
// a specific channel and message body. The stamp format
|
||||
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
||||
// bodyhash is the hex-encoded SHA-256 of the message
|
||||
// body bytes. Delegates to the internal/hashcash package.
|
||||
func MintChannelHashcash(
|
||||
bits int,
|
||||
channel string,
|
||||
body []byte,
|
||||
) string {
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
return hashcash.MintChannelStamp(
|
||||
bits, channel, bodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if hash has at least numBits
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(
|
||||
|
||||
@@ -5,7 +5,7 @@ import "time"
|
||||
// SessionRequest is the body for POST /api/v1/session.
|
||||
type SessionRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||
Hashcash string `json:"hashcash,omitempty"`
|
||||
}
|
||||
|
||||
// SessionResponse is the response from session creation.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -746,8 +746,8 @@ func scanMessages(
|
||||
code, _ := strconv.Atoi(msg.Command)
|
||||
msg.Code = code
|
||||
|
||||
if mt, err := irc.FromInt(code); err == nil {
|
||||
msg.Command = mt.Name()
|
||||
if name := irc.Name(code); name != "" {
|
||||
msg.Command = name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,121 +1110,6 @@ func (database *Database) GetSessionCreatedAt(
|
||||
return createdAt, nil
|
||||
}
|
||||
|
||||
// SetAway sets the away message for a session.
|
||||
// An empty message clears the away status.
|
||||
func (database *Database) SetAway(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
message string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
"UPDATE sessions SET away_message = ? WHERE id = ?",
|
||||
message, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set away: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAway returns the away message for a session.
|
||||
// Returns an empty string if the user is not away.
|
||||
func (database *Database) GetAway(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (string, error) {
|
||||
var msg string
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
"SELECT away_message FROM sessions WHERE id = ?",
|
||||
sessionID,
|
||||
).Scan(&msg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get away: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SetTopicMeta sets the topic along with who set it and
|
||||
// when.
|
||||
func (database *Database) SetTopicMeta(
|
||||
ctx context.Context,
|
||||
channelName, topic, setBy string,
|
||||
) error {
|
||||
now := time.Now()
|
||||
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET topic = ?, topic_set_by = ?,
|
||||
topic_set_at = ?, updated_at = ?
|
||||
WHERE name = ?`,
|
||||
topic, setBy, now, now, channelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set topic meta: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopicMeta holds topic metadata for a channel.
|
||||
type TopicMeta struct {
|
||||
SetBy string
|
||||
SetAt time.Time
|
||||
}
|
||||
|
||||
// GetTopicMeta returns who set the topic and when.
|
||||
func (database *Database) GetTopicMeta(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (*TopicMeta, error) {
|
||||
var (
|
||||
setBy string
|
||||
setAt sql.NullTime
|
||||
)
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT topic_set_by, topic_set_at
|
||||
FROM channels WHERE id = ?`,
|
||||
channelID,
|
||||
).Scan(&setBy, &setAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get topic meta: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if setBy == "" || !setAt.Valid {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
return &TopicMeta{
|
||||
SetBy: setBy,
|
||||
SetAt: setAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSessionLastSeen returns the last_seen time for a
|
||||
// session.
|
||||
func (database *Database) GetSessionLastSeen(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (time.Time, error) {
|
||||
var lastSeen time.Time
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
"SELECT last_seen FROM sessions WHERE id = ?",
|
||||
sessionID,
|
||||
).Scan(&lastSeen)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"get session last_seen: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return lastSeen, nil
|
||||
}
|
||||
|
||||
// PruneOldQueueEntries deletes client output queue entries
|
||||
// older than cutoff and returns the number of rows removed.
|
||||
func (database *Database) PruneOldQueueEntries(
|
||||
@@ -1266,110 +1151,3 @@ func (database *Database) PruneOldMessages(
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// GetChannelHashcashBits returns the hashcash difficulty
|
||||
// requirement for a channel. Returns 0 if not set.
|
||||
func (database *Database) GetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (int, error) {
|
||||
var bits int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT hashcash_bits FROM channels WHERE id = ?",
|
||||
channelID,
|
||||
).Scan(&bits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"get channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
// SetChannelHashcashBits sets the hashcash difficulty
|
||||
// requirement for a channel. A value of 0 disables the
|
||||
// requirement.
|
||||
func (database *Database) SetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
bits int,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET hashcash_bits = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
bits, time.Now(), channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"set channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSpentHashcash stores a spent hashcash stamp hash
|
||||
// for replay prevention.
|
||||
func (database *Database) RecordSpentHashcash(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO spent_hashcash
|
||||
(stamp_hash, created_at)
|
||||
VALUES (?, ?)`,
|
||||
stampHash, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"record spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsHashcashSpent checks whether a hashcash stamp hash
|
||||
// has already been used.
|
||||
func (database *Database) IsHashcashSpent(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) (bool, error) {
|
||||
var count int
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM spent_hashcash
|
||||
WHERE stamp_hash = ?`,
|
||||
stampHash,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// PruneSpentHashcash deletes spent hashcash tokens older
|
||||
// than the cutoff and returns the number of rows removed.
|
||||
func (database *Database) PruneSpentHashcash(
|
||||
ctx context.Context,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
"DELETE FROM spent_hashcash WHERE created_at < ?",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"prune spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
nick TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
signing_key TEXT NOT NULL DEFAULT '',
|
||||
away_message TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -31,9 +30,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||
topic_set_at DATETIME,
|
||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -62,14 +58,6 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
||||
|
||||
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||
CREATE TABLE IF NOT EXISTS spent_hashcash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stamp_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
|
||||
|
||||
-- Per-client message queues for fan-out delivery
|
||||
CREATE TABLE IF NOT EXISTS client_queues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -12,14 +11,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
errHashcashRequired = errors.New("hashcash required")
|
||||
errHashcashReused = errors.New("hashcash reused")
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
var validNickRe = regexp.MustCompile(
|
||||
@@ -78,10 +71,11 @@ func (hdlr *Handlers) requireAuth(
|
||||
sessionID, clientID, nick, err :=
|
||||
hdlr.authSession(request)
|
||||
if err != nil {
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"error": "not registered",
|
||||
"numeric": irc.ErrNotRegistered,
|
||||
}, http.StatusUnauthorized)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"unauthorized",
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return 0, 0, "", false
|
||||
}
|
||||
@@ -95,11 +89,10 @@ func (hdlr *Handlers) fanOut(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (string, error) {
|
||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||
request.Context(), command, from, target, nil, body, meta,
|
||||
request.Context(), command, from, target, nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert message: %w", err)
|
||||
@@ -125,11 +118,10 @@ func (hdlr *Handlers) fanOutSilent(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) error {
|
||||
_, err := hdlr.fanOut(
|
||||
request, command, from, target, body, meta, sessionIDs,
|
||||
request, command, from, target, body, sessionIDs,
|
||||
)
|
||||
|
||||
return err
|
||||
@@ -155,7 +147,7 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
) {
|
||||
type createRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||
Hashcash string `json:"hashcash,omitempty"`
|
||||
}
|
||||
|
||||
var payload createRequest
|
||||
@@ -300,7 +292,7 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
[]string{
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
"CHANMODES=,,H," + "imnst",
|
||||
"CHANMODES=,,," + "imnst",
|
||||
"NETWORK=neoirc",
|
||||
"CASEMAPPING=ascii",
|
||||
},
|
||||
@@ -433,12 +425,12 @@ func (hdlr *Handlers) serverName() string {
|
||||
func (hdlr *Handlers) enqueueNumeric(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
code irc.IRCMessageType,
|
||||
code int,
|
||||
nick string,
|
||||
params []string,
|
||||
text string,
|
||||
) {
|
||||
command := code.Code()
|
||||
command := fmt.Sprintf("%03d", code)
|
||||
|
||||
body, err := json.Marshal([]string{text})
|
||||
if err != nil {
|
||||
@@ -831,7 +823,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
payload.Command, payload.To,
|
||||
payload.Body, payload.Meta, bodyLines,
|
||||
payload.Body, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -842,20 +834,14 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdAway:
|
||||
hdlr.handleAway(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||
hdlr.handlePrivmsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, meta, bodyLines,
|
||||
command, target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdJoin:
|
||||
hdlr.handleJoin(
|
||||
@@ -956,14 +942,13 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNoRecipient, nick, []string{command},
|
||||
"No recipient given",
|
||||
irc.ErrNeedMoreParams, nick, []string{command},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -977,8 +962,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
if len(lines) == 0 {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNoTextToSend, nick, []string{command},
|
||||
"No text to send",
|
||||
irc.ErrNeedMoreParams, nick, []string{command},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -992,7 +977,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
hdlr.handleChannelMsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, meta,
|
||||
command, target, body,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1001,7 +986,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
hdlr.handleDirectMsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, meta,
|
||||
command, target, body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1011,7 +996,7 @@ func (hdlr *Handlers) respondIRCError(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
code irc.IRCMessageType,
|
||||
code int,
|
||||
nick string,
|
||||
params []string,
|
||||
text string,
|
||||
@@ -1032,7 +1017,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
request.Context(), target,
|
||||
@@ -1066,187 +1050,23 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
if !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Cannot send to channel",
|
||||
irc.ErrNotOnChannel, nick, []string{target},
|
||||
"You're not on that channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hashcashErr := hdlr.validateChannelHashcash(
|
||||
request, clientID, sessionID,
|
||||
writer, nick, target, body, meta, chID,
|
||||
)
|
||||
if hashcashErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.sendChannelMsg(
|
||||
writer, request, command, nick, target,
|
||||
body, meta, chID,
|
||||
writer, request, command, nick, target, body, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// validateChannelHashcash checks whether the channel
|
||||
// requires hashcash proof-of-work for messages and
|
||||
// validates the stamp from the message meta field.
|
||||
// Returns nil on success or if the channel has no
|
||||
// hashcash requirement. On failure, it sends the
|
||||
// appropriate IRC error and returns a non-nil error.
|
||||
func (hdlr *Handlers) validateChannelHashcash(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
writer http.ResponseWriter,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
|
||||
ctx, chID,
|
||||
)
|
||||
if bitsErr != nil {
|
||||
hdlr.log.Error(
|
||||
"get channel hashcash bits", "error", bitsErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
|
||||
}
|
||||
|
||||
if bits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stamp := hdlr.extractHashcashFromMeta(meta)
|
||||
if stamp == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Channel requires hashcash proof-of-work",
|
||||
)
|
||||
|
||||
return errHashcashRequired
|
||||
}
|
||||
|
||||
return hdlr.verifyChannelStamp(
|
||||
request, writer,
|
||||
clientID, sessionID,
|
||||
nick, target, body, stamp, bits,
|
||||
)
|
||||
}
|
||||
|
||||
// verifyChannelStamp validates a channel hashcash stamp
|
||||
// and checks for replay attacks.
|
||||
func (hdlr *Handlers) verifyChannelStamp(
|
||||
request *http.Request,
|
||||
writer http.ResponseWriter,
|
||||
clientID, sessionID int64,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
stamp string,
|
||||
bits int,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
bodyHashStr := hashcash.BodyHash(body)
|
||||
|
||||
valErr := hdlr.channelHashcash.ValidateStamp(
|
||||
stamp, bits, target, bodyHashStr,
|
||||
)
|
||||
if valErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Invalid hashcash: "+valErr.Error(),
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash: %w", valErr)
|
||||
}
|
||||
|
||||
stampKey := hashcash.StampHash(stamp)
|
||||
|
||||
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if spentErr != nil {
|
||||
hdlr.log.Error(
|
||||
"check spent hashcash", "error", spentErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("check spent hashcash: %w", spentErr)
|
||||
}
|
||||
|
||||
if spent {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Hashcash stamp already used",
|
||||
)
|
||||
|
||||
return errHashcashReused
|
||||
}
|
||||
|
||||
recordErr := hdlr.params.Database.RecordSpentHashcash(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if recordErr != nil {
|
||||
hdlr.log.Error(
|
||||
"record spent hashcash", "error", recordErr,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHashcashFromMeta parses the meta JSON and
|
||||
// returns the hashcash stamp string, or empty string
|
||||
// if not present.
|
||||
func (hdlr *Handlers) extractHashcashFromMeta(
|
||||
meta json.RawMessage,
|
||||
) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var metaMap map[string]json.RawMessage
|
||||
|
||||
err := json.Unmarshal(meta, &metaMap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw, ok := metaMap["hashcash"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var stamp string
|
||||
|
||||
err = json.Unmarshal(raw, &stamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stamp
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) sendChannelMsg(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
command, nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) {
|
||||
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
||||
@@ -1266,7 +1086,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
||||
}
|
||||
|
||||
msgUUID, err := hdlr.fanOut(
|
||||
request, command, nick, target, body, meta, memberIDs,
|
||||
request, command, nick, target, body, memberIDs,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("send message failed", "error", err)
|
||||
@@ -1290,7 +1110,6 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
sessionID, clientID int64,
|
||||
nick, command, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
||||
request.Context(), target,
|
||||
@@ -1315,7 +1134,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
}
|
||||
|
||||
msgUUID, err := hdlr.fanOut(
|
||||
request, command, nick, target, body, meta, recipients,
|
||||
request, command, nick, target, body, recipients,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error("send dm failed", "error", err)
|
||||
@@ -1328,19 +1147,6 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
return
|
||||
}
|
||||
|
||||
// If the target is away, send RPL_AWAY to the sender.
|
||||
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
||||
request.Context(), targetSID,
|
||||
)
|
||||
if awayErr == nil && awayMsg != "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplAway, nick,
|
||||
[]string{target}, awayMsg,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"id": msgUUID, "status": "sent"},
|
||||
http.StatusOK)
|
||||
@@ -1426,7 +1232,7 @@ func (hdlr *Handlers) executeJoin(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.deliverJoinNumerics(
|
||||
@@ -1451,25 +1257,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
hdlr.deliverTopicNumerics(
|
||||
ctx, clientID, sessionID, nick, channel, chID,
|
||||
chInfo, err := hdlr.params.Database.GetChannelByName(
|
||||
ctx, channel,
|
||||
)
|
||||
if err == nil {
|
||||
_ = chInfo // chInfo is the ID; topic comes from DB.
|
||||
}
|
||||
|
||||
hdlr.deliverNamesNumerics(
|
||||
ctx, clientID, nick, channel, chID,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
// deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC,
|
||||
// plus RPL_TOPICWHOTIME when topic metadata is available.
|
||||
func (hdlr *Handlers) deliverTopicNumerics(
|
||||
ctx context.Context,
|
||||
clientID, sessionID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
// Get topic from channel info.
|
||||
channels, listErr := hdlr.params.Database.ListChannels(
|
||||
ctx, sessionID,
|
||||
)
|
||||
@@ -1491,39 +1286,14 @@ func (hdlr *Handlers) deliverTopicNumerics(
|
||||
ctx, clientID, irc.RplTopic, nick,
|
||||
[]string{channel}, topic,
|
||||
)
|
||||
|
||||
topicMeta, tmErr := hdlr.params.Database.
|
||||
GetTopicMeta(ctx, chID)
|
||||
if tmErr == nil && topicMeta != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID,
|
||||
irc.RplTopicWhoTime, nick,
|
||||
[]string{
|
||||
channel,
|
||||
topicMeta.SetBy,
|
||||
strconv.FormatInt(
|
||||
topicMeta.SetAt.Unix(), 10,
|
||||
),
|
||||
},
|
||||
"",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplNoTopic, nick,
|
||||
[]string{channel}, "No topic is set",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverNamesNumerics sends RPL_NAMREPLY and
|
||||
// RPL_ENDOFNAMES for a channel.
|
||||
func (hdlr *Handlers) deliverNamesNumerics(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
// Get member list for NAMES reply.
|
||||
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||
ctx, chID,
|
||||
)
|
||||
@@ -1546,6 +1316,8 @@ func (hdlr *Handlers) deliverNamesNumerics(
|
||||
ctx, clientID, irc.RplEndOfNames, nick,
|
||||
[]string{channel}, "End of /NAMES list",
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handlePart(
|
||||
@@ -1596,7 +1368,7 @@ func (hdlr *Handlers) handlePart(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
err = hdlr.params.Database.PartChannel(
|
||||
@@ -1829,8 +1601,8 @@ func (hdlr *Handlers) executeTopic(
|
||||
body json.RawMessage,
|
||||
chID int64,
|
||||
) {
|
||||
setErr := hdlr.params.Database.SetTopicMeta(
|
||||
request.Context(), channel, topic, nick,
|
||||
setErr := hdlr.params.Database.SetTopic(
|
||||
request.Context(), channel, topic,
|
||||
)
|
||||
if setErr != nil {
|
||||
hdlr.log.Error(
|
||||
@@ -1850,32 +1622,13 @@ func (hdlr *Handlers) executeTopic(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplTopic, nick, []string{channel}, topic,
|
||||
)
|
||||
|
||||
// 333 RPL_TOPICWHOTIME
|
||||
topicMeta, tmErr := hdlr.params.Database.
|
||||
GetTopicMeta(request.Context(), chID)
|
||||
if tmErr == nil && topicMeta != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplTopicWhoTime, nick,
|
||||
[]string{
|
||||
channel,
|
||||
topicMeta.SetBy,
|
||||
strconv.FormatInt(
|
||||
topicMeta.SetAt.Unix(), 10,
|
||||
),
|
||||
},
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -2013,10 +1766,11 @@ func (hdlr *Handlers) handleMode(
|
||||
return
|
||||
}
|
||||
|
||||
_ = bodyLines
|
||||
|
||||
hdlr.handleChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel,
|
||||
bodyLines,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2025,7 +1779,6 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
@@ -2042,47 +1795,10 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) > 0 {
|
||||
hdlr.applyChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, lines,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.queryChannelMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||
// RPL_CREATIONTIME for a channel. Includes +H if
|
||||
// the channel has a hashcash requirement.
|
||||
func (hdlr *Handlers) queryChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
modeStr := "+n"
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.
|
||||
GetChannelHashcashBits(ctx, chID)
|
||||
if bitsErr == nil && bits > 0 {
|
||||
modeStr = fmt.Sprintf("+nH %d", bits)
|
||||
}
|
||||
|
||||
// 324 RPL_CHANNELMODEIS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, modeStr}, "",
|
||||
[]string{channel, "+n"}, "",
|
||||
)
|
||||
|
||||
// 329 RPL_CREATIONTIME
|
||||
@@ -2107,156 +1823,6 @@ func (hdlr *Handlers) queryChannelMode(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// applyChannelMode handles setting channel modes.
|
||||
// Currently supports +H/-H for hashcash bits.
|
||||
func (hdlr *Handlers) applyChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
modeStr := modeArgs[0]
|
||||
|
||||
switch modeStr {
|
||||
case "+H":
|
||||
hdlr.setHashcashMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, modeArgs,
|
||||
)
|
||||
case "-H":
|
||||
hdlr.clearHashcashMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID,
|
||||
)
|
||||
default:
|
||||
// Unknown or unsupported mode change.
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.ErrUnknownMode, nick,
|
||||
[]string{modeStr},
|
||||
"is unknown mode char to me",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "error"},
|
||||
http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// minHashcashBits is the minimum allowed hashcash
|
||||
// difficulty for channels.
|
||||
minHashcashBits = 1
|
||||
// maxHashcashBits is the maximum allowed hashcash
|
||||
// difficulty for channels.
|
||||
maxHashcashBits = 40
|
||||
)
|
||||
|
||||
// setHashcashMode handles MODE #channel +H <bits>.
|
||||
func (hdlr *Handlers) setHashcashMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
||||
"Not enough parameters (+H requires bits)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bits, err := strconv.Atoi(modeArgs[1])
|
||||
if err != nil || bits < minHashcashBits ||
|
||||
bits > maxHashcashBits {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUnknownMode, nick, []string{"+H"},
|
||||
fmt.Sprintf(
|
||||
"Invalid hashcash bits (must be %d-%d)",
|
||||
minHashcashBits, maxHashcashBits,
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = hdlr.params.Database.SetChannelHashcashBits(
|
||||
ctx, chID, bits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set channel hashcash bits", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{
|
||||
channel,
|
||||
fmt.Sprintf("+H %d", bits),
|
||||
}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// clearHashcashMode handles MODE #channel -H.
|
||||
func (hdlr *Handlers) clearHashcashMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
err := hdlr.params.Database.SetChannelHashcashBits(
|
||||
ctx, chID, 0,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"clear channel hashcash bits", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, "+n"}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleNames sends NAMES reply for a channel.
|
||||
func (hdlr *Handlers) handleNames(
|
||||
writer http.ResponseWriter,
|
||||
@@ -2452,11 +2018,6 @@ func (hdlr *Handlers) executeWhois(
|
||||
"neoirc server",
|
||||
)
|
||||
|
||||
// 317 RPL_WHOISIDLE
|
||||
hdlr.deliverWhoisIdle(
|
||||
ctx, clientID, nick, queryNick, targetSID,
|
||||
)
|
||||
|
||||
// 319 RPL_WHOISCHANNELS
|
||||
hdlr.deliverWhoisChannels(
|
||||
ctx, clientID, nick, queryNick, targetSID,
|
||||
@@ -2874,95 +2435,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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
@@ -812,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "412") {
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTEXTTOSEND (412), got %v",
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
@@ -836,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "411") {
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NORECIPIENT (411), got %v",
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
@@ -871,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
|
||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "404") {
|
||||
if !findNumeric(msgs, "442") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
@@ -1989,397 +1988,3 @@ func TestNickBroadcastToChannels(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Channel Hashcash Tests ---
|
||||
|
||||
const (
|
||||
metaKey = "meta"
|
||||
modeCmd = "MODE"
|
||||
hashcashKey = "hashcash"
|
||||
)
|
||||
|
||||
func mintTestChannelHashcash(
|
||||
tb testing.TB,
|
||||
bits int,
|
||||
channel string,
|
||||
body json.RawMessage,
|
||||
) string {
|
||||
tb.Helper()
|
||||
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
return hashcash.MintChannelStamp(bits, channel, bodyHash)
|
||||
}
|
||||
|
||||
func TestChannelHashcashSetMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcmode_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hctest",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Set hashcash bits to 2 via MODE +H.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hctest",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get RPL_CHANNELMODEIS (324) confirming +H.
|
||||
if !findNumeric(msgs, "324") {
|
||||
t.Fatalf(
|
||||
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashQueryMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcquery_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcquery",
|
||||
})
|
||||
|
||||
// Set hashcash bits.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcquery",
|
||||
bodyKey: []string{"+H", "5"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Query mode — should show +nH.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcquery",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
found := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if ok && int(code) == 324 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatalf(
|
||||
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashClearMode(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcclear_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcclear",
|
||||
})
|
||||
|
||||
// Set hashcash bits.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"+H", "5"},
|
||||
})
|
||||
|
||||
// Clear hashcash bits.
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"-H"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
// Now message should succeed without hashcash.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcclear",
|
||||
bodyKey: []string{"test message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashRejectNoStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcreject_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcreject",
|
||||
})
|
||||
|
||||
// Set hashcash requirement.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcreject",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Send message without hashcash — should fail.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreject",
|
||||
bodyKey: []string{"spam message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcaccept_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcaccept",
|
||||
})
|
||||
|
||||
// Set hashcash requirement (2 bits = fast to mint).
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcaccept",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Mint a valid hashcash stamp.
|
||||
msgBody, marshalErr := json.Marshal(
|
||||
[]string{"hello world"},
|
||||
)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
|
||||
stamp := mintTestChannelHashcash(
|
||||
t, 2, "#hcaccept", msgBody,
|
||||
)
|
||||
|
||||
// Send message with valid hashcash.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcaccept",
|
||||
bodyKey: []string{"hello world"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result["id"] == nil || result["id"] == "" {
|
||||
t.Fatal("expected message id for valid hashcash")
|
||||
}
|
||||
|
||||
// Verify the message was delivered.
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
|
||||
t.Fatalf(
|
||||
"message not received: %v", msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcreplay_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcreplay",
|
||||
})
|
||||
|
||||
// Set hashcash requirement.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"+H", "2"},
|
||||
})
|
||||
|
||||
_, _ = tserver.pollMessages(token, 0)
|
||||
|
||||
// Mint and send once — should succeed.
|
||||
msgBody, marshalErr := json.Marshal(
|
||||
[]string{"unique msg"},
|
||||
)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
|
||||
stamp := mintTestChannelHashcash(
|
||||
t, 2, "#hcreplay", msgBody,
|
||||
)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"unique msg"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Replay the same stamp — should fail.
|
||||
status, _ = tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#hcreplay",
|
||||
bodyKey: []string{"unique msg"},
|
||||
metaKey: map[string]any{
|
||||
hashcashKey: stamp,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected replay rejection (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcnone_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#nohashcash",
|
||||
})
|
||||
|
||||
// No hashcash set — message should work.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#nohashcash",
|
||||
bodyKey: []string{"free message"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result["id"] == nil || result["id"] == "" {
|
||||
t.Fatal("expected message id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcbits_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcbits",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set bits to 0 — should fail.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcbits",
|
||||
bodyKey: []string{"+H", "0"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "472") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UNKNOWNMODE (472), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("hcnoarg_user")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#hcnoarg",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set +H without bits argument.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#hcnoarg",
|
||||
bodyKey: []string{"+H"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,20 +34,14 @@ type Params struct {
|
||||
|
||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||
|
||||
// spentHashcashTTL is how long spent hashcash tokens are
|
||||
// retained for replay prevention. Per issue requirements,
|
||||
// this is 1 year.
|
||||
const spentHashcashTTL = 365 * 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
cancelCleanup context.CancelFunc
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
@@ -61,12 +55,11 @@ func New(
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
@@ -288,20 +281,4 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune spent hashcash tokens older than 1 year.
|
||||
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
|
||||
|
||||
pruned, err := hdlr.params.Database.
|
||||
PruneSpentHashcash(ctx, hashcashCutoff)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"spent hashcash pruning failed", "error", err,
|
||||
)
|
||||
} else if pruned > 0 {
|
||||
hdlr.log.Info(
|
||||
"pruned spent hashcash tokens",
|
||||
"deleted", pruned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errBodyHashMismatch = errors.New(
|
||||
"body hash mismatch",
|
||||
)
|
||||
errBodyHashMissing = errors.New(
|
||||
"body hash missing",
|
||||
)
|
||||
)
|
||||
|
||||
// ChannelValidator checks hashcash stamps for
|
||||
// per-channel PRIVMSG validation. It verifies that
|
||||
// stamps are bound to a specific channel and message
|
||||
// body. Replay prevention is handled externally via
|
||||
// the database spent_hashcash table for persistence
|
||||
// across server restarts (1-year TTL).
|
||||
type ChannelValidator struct{}
|
||||
|
||||
// NewChannelValidator creates a ChannelValidator.
|
||||
func NewChannelValidator() *ChannelValidator {
|
||||
return &ChannelValidator{}
|
||||
}
|
||||
|
||||
// BodyHash computes the hex-encoded SHA-256 hash of a
|
||||
// message body for use in hashcash stamp validation.
|
||||
func BodyHash(body []byte) string {
|
||||
hash := sha256.Sum256(body)
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ValidateStamp checks a channel hashcash stamp. It
|
||||
// verifies the stamp format, difficulty, date, channel
|
||||
// binding, body hash binding, and proof-of-work. Replay
|
||||
// detection is NOT performed here — callers must check
|
||||
// the spent_hashcash table separately.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func (cv *ChannelValidator) ValidateStamp(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
}
|
||||
|
||||
version := parts[0]
|
||||
bitsStr := parts[1]
|
||||
dateStr := parts[2]
|
||||
resource := parts[3]
|
||||
stampBodyHash := parts[4]
|
||||
|
||||
headerErr := validateChannelHeader(
|
||||
version, bitsStr, resource,
|
||||
requiredBits, channel,
|
||||
)
|
||||
if headerErr != nil {
|
||||
return headerErr
|
||||
}
|
||||
|
||||
stampTime, parseErr := parseStampDate(dateStr)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
timeErr := validateTime(stampTime)
|
||||
if timeErr != nil {
|
||||
return timeErr
|
||||
}
|
||||
|
||||
bodyErr := validateBodyHash(
|
||||
stampBodyHash, bodyHash,
|
||||
)
|
||||
if bodyErr != nil {
|
||||
return bodyErr
|
||||
}
|
||||
|
||||
return validateProof(stamp, requiredBits)
|
||||
}
|
||||
|
||||
// StampHash returns a deterministic hash of a stamp
|
||||
// string for use as a spent-token key.
|
||||
func StampHash(stamp string) string {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func validateChannelHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
) error {
|
||||
if version != stampVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errBadVersion, version,
|
||||
)
|
||||
}
|
||||
|
||||
claimedBits, err := strconv.Atoi(bitsStr)
|
||||
if err != nil || claimedBits < requiredBits {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d bits",
|
||||
errInsufficientBits, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
if resource != channel {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, channel,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBodyHash(
|
||||
stampBodyHash, expectedBodyHash string,
|
||||
) error {
|
||||
if stampBodyHash == "" {
|
||||
return errBodyHashMissing
|
||||
}
|
||||
|
||||
if stampBodyHash != expectedBodyHash {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errBodyHashMismatch,
|
||||
stampBodyHash, expectedBodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MintChannelStamp computes a channel hashcash stamp
|
||||
// with the given difficulty, channel name, and body hash.
|
||||
// This is intended for clients to generate stamps before
|
||||
// sending PRIVMSG to hashcash-protected channels.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func MintChannelStamp(
|
||||
bits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) string {
|
||||
date := time.Now().UTC().Format(dateFormatShort)
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s:%s:",
|
||||
bits, date, channel, bodyHash,
|
||||
)
|
||||
|
||||
counter := uint64(0)
|
||||
|
||||
for {
|
||||
stamp := prefix + strconv.FormatUint(counter, 16)
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package hashcash_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
testChannel = "#general"
|
||||
testBodyText = `["hello world"]`
|
||||
)
|
||||
|
||||
func testBodyHash() string {
|
||||
hash := sha256.Sum256([]byte(testBodyText))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func TestChannelValidateHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("valid channel stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, "#other", bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected channel mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
wrongHash := sha256.Sum256([]byte("different body"))
|
||||
wrongBodyHash := hex.EncodeToString(wrongHash[:])
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, wrongBodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected body hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateInsufficientBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with 2 bits but require 4.
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, 4, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient bits error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateZeroBitsSkips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"garbage", 0, "#ch", "abc",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"not:valid", testBits, testChannel, "abc",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateExpiredStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with a very old date by manually constructing.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, "200101",
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired stamp error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateMissingBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Construct a stamp with empty body hash field.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, todayDate(),
|
||||
)
|
||||
|
||||
// This uses the session-style stamp which has empty
|
||||
// ext field — body hash is missing.
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing body hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`["hello world"]`)
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
if len(bodyHash) != 64 {
|
||||
t.Fatalf(
|
||||
"expected 64-char hex hash, got %d",
|
||||
len(bodyHash),
|
||||
)
|
||||
}
|
||||
|
||||
// Same input should produce same hash.
|
||||
bodyHash2 := hashcash.BodyHash(body)
|
||||
if bodyHash != bodyHash2 {
|
||||
t.Fatal("body hash not deterministic")
|
||||
}
|
||||
|
||||
// Different input should produce different hash.
|
||||
bodyHash3 := hashcash.BodyHash([]byte("different"))
|
||||
if bodyHash == bodyHash3 {
|
||||
t.Fatal("different inputs produced same hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash1 := hashcash.StampHash("stamp1")
|
||||
hash2 := hashcash.StampHash("stamp2")
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("different stamps produced same hash")
|
||||
}
|
||||
|
||||
// Same input should be deterministic.
|
||||
hash1b := hashcash.StampHash("stamp1")
|
||||
if hash1 != hash1b {
|
||||
t.Fatal("stamp hash not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintChannelStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bodyHash := testBodyHash()
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
if stamp == "" {
|
||||
t.Fatal("expected non-empty stamp")
|
||||
}
|
||||
|
||||
// Validate the minted stamp.
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("minted stamp failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,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).
|
||||
const (
|
||||
CmdAway = "AWAY"
|
||||
CmdJoin = "JOIN"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
150
internal/irc/numerics.go
Normal file
150
internal/irc/numerics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Package irc provides constants and utilities for the
|
||||
// IRC protocol, including numeric reply codes from
|
||||
// RFC 1459 and RFC 2812, and standard command names.
|
||||
package irc
|
||||
|
||||
// Connection registration replies (001-005).
|
||||
const (
|
||||
RplWelcome = 1
|
||||
RplYourHost = 2
|
||||
RplCreated = 3
|
||||
RplMyInfo = 4
|
||||
RplIsupport = 5
|
||||
)
|
||||
|
||||
// Command responses (200-399).
|
||||
const (
|
||||
RplUmodeIs = 221
|
||||
RplLuserClient = 251
|
||||
RplLuserOp = 252
|
||||
RplLuserUnknown = 253
|
||||
RplLuserChannels = 254
|
||||
RplLuserMe = 255
|
||||
RplAway = 301
|
||||
RplUserHost = 302
|
||||
RplIson = 303
|
||||
RplUnaway = 305
|
||||
RplNowAway = 306
|
||||
RplWhoisUser = 311
|
||||
RplWhoisServer = 312
|
||||
RplWhoisOperator = 313
|
||||
RplEndOfWho = 315
|
||||
RplWhoisIdle = 317
|
||||
RplEndOfWhois = 318
|
||||
RplWhoisChannels = 319
|
||||
RplList = 322
|
||||
RplListEnd = 323
|
||||
RplChannelModeIs = 324
|
||||
RplCreationTime = 329
|
||||
RplNoTopic = 331
|
||||
RplTopic = 332
|
||||
RplTopicWhoTime = 333
|
||||
RplInviting = 341
|
||||
RplWhoReply = 352
|
||||
RplNamReply = 353
|
||||
RplEndOfNames = 366
|
||||
RplBanList = 367
|
||||
RplEndOfBanList = 368
|
||||
RplMotd = 372
|
||||
RplMotdStart = 375
|
||||
RplEndOfMotd = 376
|
||||
)
|
||||
|
||||
// Error replies (400-599).
|
||||
const (
|
||||
ErrNoSuchNick = 401
|
||||
ErrNoSuchServer = 402
|
||||
ErrNoSuchChannel = 403
|
||||
ErrCannotSendToChan = 404
|
||||
ErrTooManyChannels = 405
|
||||
ErrNoRecipient = 411
|
||||
ErrNoTextToSend = 412
|
||||
ErrUnknownCommand = 421
|
||||
ErrNoNicknameGiven = 431
|
||||
ErrErroneusNickname = 432
|
||||
ErrNicknameInUse = 433
|
||||
ErrUserNotInChannel = 441
|
||||
ErrNotOnChannel = 442
|
||||
ErrNotRegistered = 451
|
||||
ErrNeedMoreParams = 461
|
||||
ErrAlreadyRegistered = 462
|
||||
ErrChannelIsFull = 471
|
||||
ErrInviteOnlyChan = 473
|
||||
ErrBannedFromChan = 474
|
||||
ErrBadChannelKey = 475
|
||||
ErrChanOpPrivsNeeded = 482
|
||||
)
|
||||
|
||||
// names maps numeric codes to their standard IRC names.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var names = map[int]string{
|
||||
RplWelcome: "RPL_WELCOME",
|
||||
RplYourHost: "RPL_YOURHOST",
|
||||
RplCreated: "RPL_CREATED",
|
||||
RplMyInfo: "RPL_MYINFO",
|
||||
RplIsupport: "RPL_ISUPPORT",
|
||||
RplUmodeIs: "RPL_UMODEIS",
|
||||
RplLuserClient: "RPL_LUSERCLIENT",
|
||||
RplLuserOp: "RPL_LUSEROP",
|
||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||
RplLuserMe: "RPL_LUSERME",
|
||||
RplAway: "RPL_AWAY",
|
||||
RplUserHost: "RPL_USERHOST",
|
||||
RplIson: "RPL_ISON",
|
||||
RplUnaway: "RPL_UNAWAY",
|
||||
RplNowAway: "RPL_NOWAWAY",
|
||||
RplWhoisUser: "RPL_WHOISUSER",
|
||||
RplWhoisServer: "RPL_WHOISSERVER",
|
||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||
RplEndOfWho: "RPL_ENDOFWHO",
|
||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||
RplList: "RPL_LIST",
|
||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||
RplCreationTime: "RPL_CREATIONTIME",
|
||||
RplNoTopic: "RPL_NOTOPIC",
|
||||
RplTopic: "RPL_TOPIC",
|
||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||
RplInviting: "RPL_INVITING",
|
||||
RplWhoReply: "RPL_WHOREPLY",
|
||||
RplNamReply: "RPL_NAMREPLY",
|
||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||
RplBanList: "RPL_BANLIST",
|
||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||
RplMotd: "RPL_MOTD",
|
||||
RplMotdStart: "RPL_MOTDSTART",
|
||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||
|
||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||
}
|
||||
|
||||
// Name returns the standard IRC name for a numeric code
|
||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||
// empty string if the code is unknown.
|
||||
func Name(code int) string {
|
||||
return names[code]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/web"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
_ "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")
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function LoginScreen({ onLogin }) {
|
||||
}
|
||||
const reqBody = { nick: nick.trim() };
|
||||
if (hashcashStamp) {
|
||||
reqBody.pow_token = hashcashStamp;
|
||||
reqBody.hashcash = hashcashStamp;
|
||||
}
|
||||
const res = await api("/session", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user