Compare commits
1 Commits
feature/co
...
f2af55e4a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2af55e4a5 |
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||||
|
|
||||||
BINARY := neoircd
|
BINARY := neoircd
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
@@ -7,21 +7,10 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
|
|
||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
build:
|
||||||
# web/embed.go resolves without a full Node.js build. The real SPA is
|
|
||||||
# built by the web-builder Docker stage; these placeholders let
|
|
||||||
# "make test" and "make build" work outside Docker.
|
|
||||||
ensure-web-dist:
|
|
||||||
@if [ ! -d web/dist ]; then \
|
|
||||||
mkdir -p web/dist && \
|
|
||||||
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
|
|
||||||
echo "==> Created placeholder web/dist/ for go:embed"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
build: ensure-web-dist
|
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||||
|
|
||||||
lint: ensure-web-dist
|
lint:
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@@ -31,7 +20,7 @@ fmt:
|
|||||||
fmt-check:
|
fmt-check:
|
||||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
test: ensure-web-dist
|
test:
|
||||||
go test -timeout 30s -v -race -cover ./...
|
go test -timeout 30s -v -race -cover ./...
|
||||||
|
|
||||||
# check runs all validation without making changes
|
# check runs all validation without making changes
|
||||||
|
|||||||
507
README.md
507
README.md
@@ -115,7 +115,7 @@ Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
|||||||
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
||||||
are unique per server. Identity starts with a key — a nick is a display name.
|
are unique per server. Identity starts with a key — a nick is a display name.
|
||||||
Accounts are optional: you can create an anonymous session instantly, or
|
Accounts are optional: you can create an anonymous session instantly, or
|
||||||
set a password via the PASS command for multi-client access to a single session.
|
register with a password for multi-client access to a single session.
|
||||||
|
|
||||||
### On the resemblance to JSON-RPC
|
### On the resemblance to JSON-RPC
|
||||||
|
|
||||||
@@ -149,59 +149,63 @@ not arbitrary choices — each one follows from the project's core thesis that
|
|||||||
IRC's command model is correct and only the transport and session management
|
IRC's command model is correct and only the transport and session management
|
||||||
need to change.
|
need to change.
|
||||||
|
|
||||||
### Identity & Sessions — Cookie-Based Authentication
|
### Identity & Sessions — Dual Authentication Model
|
||||||
|
|
||||||
The server uses **HTTP cookies** for all authentication. There is no separate
|
The server supports two authentication paths: **anonymous sessions** for
|
||||||
registration step — sessions start anonymous and can optionally set a password
|
instant access, and **optional account registration** for multi-client access.
|
||||||
for multi-client access.
|
|
||||||
|
|
||||||
#### Session Creation
|
#### Anonymous Sessions (No Account Required)
|
||||||
|
|
||||||
|
The simplest entry point. No registration, no passwords.
|
||||||
|
|
||||||
- **Session creation**: client sends `POST /api/v1/session` with a desired
|
- **Session creation**: client sends `POST /api/v1/session` with a desired
|
||||||
nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
|
nick → server assigns an **auth token** (64 hex characters of
|
||||||
a cryptographically random token (64 hex characters) and returns the user
|
cryptographically random bytes) and returns the user ID, nick, and token.
|
||||||
ID and nick in the JSON response body. No token appears in the JSON body.
|
- The auth token implicitly identifies the client. Clients present it via
|
||||||
- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS.
|
`Authorization: Bearer <token>`.
|
||||||
Clients never need to handle the token directly — the browser/HTTP client
|
- Anonymous sessions are ephemeral — when the session expires or the user
|
||||||
manages cookies automatically.
|
QUITs, the nick is released and there is no way to reclaim it.
|
||||||
- Sessions start anonymous — no password required. When the session expires
|
|
||||||
or the user QUITs, the nick is released.
|
|
||||||
|
|
||||||
#### Setting a Password (Optional, for Multi-Client Access)
|
#### Registered Accounts (Optional)
|
||||||
|
|
||||||
For users who want to access the same session from multiple devices:
|
For users who want multi-client access (multiple devices sharing one session):
|
||||||
|
|
||||||
- **Set password via IRC PASS command**: the authenticated client sends
|
- **Registration**: client sends `POST /api/v1/register` with a nick and
|
||||||
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
|
password (minimum 8 characters) → server creates a session with the
|
||||||
The server hashes the password with bcrypt and stores it on the session.
|
password hashed via bcrypt, and returns the user ID, nick, and auth token.
|
||||||
Password must be at least 8 characters.
|
- **Login**: client sends `POST /api/v1/login` with nick and password →
|
||||||
- **Login from another client**: `POST /api/v1/login` with nick and password →
|
server verifies the password against the stored bcrypt hash and creates a
|
||||||
server verifies the password, creates a new client for the existing session,
|
new client token for the existing session. This enables multi-client
|
||||||
and sets an auth cookie. Channel memberships and message queues are shared.
|
access: logging in from a new device adds a client to the existing session
|
||||||
Login only works while the session still exists — if all clients have logged
|
rather than creating a new one, so channel memberships and message queues
|
||||||
out or the user has sent QUIT, the session is deleted and the password is
|
are shared. Note: login only works while the session still exists — if all
|
||||||
lost.
|
clients have logged out or the user has sent QUIT, the session is deleted
|
||||||
|
and the registration is lost.
|
||||||
|
- Registered accounts cannot be logged into via `POST /api/v1/session` —
|
||||||
|
that endpoint is for anonymous sessions only.
|
||||||
|
- Anonymous sessions (created via `/session`) cannot be logged into via
|
||||||
|
`/login` because they have no password set.
|
||||||
|
|
||||||
#### Common Properties
|
#### Common Properties (Both Paths)
|
||||||
|
|
||||||
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
|
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
|
||||||
the stable identity.
|
the stable identity.
|
||||||
- Server-assigned IDs — clients do not choose their own IDs.
|
- Server-assigned IDs — clients do not choose their own IDs.
|
||||||
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
|
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded
|
||||||
encoded in the token, no client-side decode. The server is the sole authority
|
in the token, no client-side decode. The server is the sole authority on
|
||||||
on cookie validity.
|
token validity.
|
||||||
|
|
||||||
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
|
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
|
||||||
Anonymous sessions preserve that simplicity — instant access, zero friction.
|
Anonymous sessions preserve that simplicity — instant access, zero friction.
|
||||||
But some users want to access the same session from multiple devices without
|
But some users want to access the same session from multiple devices without
|
||||||
a bouncer. The PASS command enables multi-client login without adding friction
|
a bouncer. Optional registration with password enables multi-client login
|
||||||
for casual users: if you don't need multi-client, just create a session and
|
without adding friction for casual users: if you don't want an account,
|
||||||
go. Cookie-based auth eliminates token management from client code entirely —
|
don't create one. Note: in the current implementation, both anonymous and
|
||||||
browsers and HTTP cookie jars handle it automatically. Note: both anonymous
|
registered sessions are deleted when the last client disconnects (QUIT or
|
||||||
and password-protected sessions are deleted when the last client disconnects
|
logout); registration does not make a session survive all-client
|
||||||
(QUIT or logout). Identity verification at the message layer via cryptographic
|
removal. Identity verification at the message layer via cryptographic
|
||||||
signatures (see [Security Model](#security-model)) remains independent of
|
signatures (see [Security Model](#security-model)) remains independent
|
||||||
password status.
|
of account registration.
|
||||||
|
|
||||||
### Nick Semantics
|
### Nick Semantics
|
||||||
|
|
||||||
@@ -228,7 +232,7 @@ A single user session can have multiple clients (phone, laptop, terminal).
|
|||||||
- The server fans out all S2C messages to every active client queue for that
|
- The server fans out all S2C messages to every active client queue for that
|
||||||
user session.
|
user session.
|
||||||
- `GET /api/v1/messages` delivers from the calling client's specific queue,
|
- `GET /api/v1/messages` delivers from the calling client's specific queue,
|
||||||
identified by the auth cookie.
|
identified by the auth token.
|
||||||
- Client queues have **independent expiry/pruning** — one client going offline
|
- Client queues have **independent expiry/pruning** — one client going offline
|
||||||
doesn't affect others.
|
doesn't affect others.
|
||||||
|
|
||||||
@@ -240,10 +244,11 @@ User Session
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
|
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
|
||||||
client to an existing session (one that has a password set via PASS command),
|
client to an existing registered session, enabling true multi-client support
|
||||||
enabling true multi-client support (multiple cookies sharing one nick/session
|
(multiple tokens sharing one nick/session with independent message queues).
|
||||||
with independent message queues). Sessions without a password cannot be
|
Anonymous sessions created via `POST /api/v1/session` always create a new
|
||||||
logged into.
|
user with a new nick. A future endpoint to "add a client to an existing
|
||||||
|
anonymous session" is planned but not yet implemented.
|
||||||
|
|
||||||
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
||||||
phone and laptop connected simultaneously without a bouncer. Server-side
|
phone and laptop connected simultaneously without a bouncer. Server-side
|
||||||
@@ -350,22 +355,19 @@ over binary is measured in bytes per message, not meaningful bandwidth. The
|
|||||||
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
|
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
|
||||||
matters for signing.
|
matters for signing.
|
||||||
|
|
||||||
### Why Opaque Cookies Instead of JWTs
|
### Why Opaque Tokens Instead of JWTs
|
||||||
|
|
||||||
JWTs encode claims that clients can decode and potentially rely on. This
|
JWTs encode claims that clients can decode and potentially rely on. This
|
||||||
creates a coupling between token format and client behavior. If the server
|
creates a coupling between token format and client behavior. If the server
|
||||||
needs to revoke a token, change the expiry model, or add/remove claims, JWT
|
needs to revoke a token, change the expiry model, or add/remove claims, JWT
|
||||||
clients may break or behave incorrectly.
|
clients may break or behave incorrectly.
|
||||||
|
|
||||||
Opaque auth cookies are simpler:
|
Opaque tokens are simpler:
|
||||||
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash →
|
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
|
||||||
sets raw hex as an HttpOnly cookie
|
- Client presents the raw token; server hashes and looks it up
|
||||||
- On each request, server hashes the cookie value and looks it up
|
- Revocation is a database delete
|
||||||
- Revocation is a database delete (cookie becomes invalid immediately)
|
|
||||||
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
||||||
- Cookie format can change without breaking clients
|
- Token format can change without breaking clients
|
||||||
- Clients never handle tokens directly — browsers and HTTP cookie jars
|
|
||||||
manage everything automatically
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -389,18 +391,17 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
|
|
||||||
### Session Lifecycle
|
### Session Lifecycle
|
||||||
|
|
||||||
#### Session Creation
|
#### Anonymous Session
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ Client ──────────────────────────────────────────────────┐
|
┌─ Client ──────────────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ 1. POST /api/v1/session {"nick":"alice"} │
|
│ 1. POST /api/v1/session {"nick":"alice"} │
|
||||||
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
|
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||||
│ → {"id":1, "nick":"alice"} │
|
|
||||||
│ │
|
│ │
|
||||||
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
|
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
|
||||||
│ → {"status":"joined","channel":"#general"} │
|
│ → {"status":"joined","channel":"#general"} │
|
||||||
│ (Cookie sent automatically on all subsequent requests) │
|
│ (Server fans out JOIN event to all #general members) │
|
||||||
│ │
|
│ │
|
||||||
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
|
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
|
||||||
│ "to":"#general","body":["hello"]} │
|
│ "to":"#general","body":["hello"]} │
|
||||||
@@ -417,37 +418,31 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
│ 6. POST /api/v1/messages {"command":"QUIT"} │
|
│ 6. POST /api/v1/messages {"command":"QUIT"} │
|
||||||
│ → {"status":"quit"} │
|
│ → {"status":"quit"} │
|
||||||
│ (Server broadcasts QUIT, removes from channels, │
|
│ (Server broadcasts QUIT, removes from channels, │
|
||||||
│ deletes session, releases nick, clears cookie) │
|
│ deletes session, releases nick) │
|
||||||
│ │
|
│ │
|
||||||
└────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Multi-Client via Password
|
#### Registered Account
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ Client A ────────────────────────────────────────────────┐
|
┌─ Client ──────────────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ 1. POST /api/v1/session {"nick":"alice"} │
|
│ 1. POST /api/v1/register │
|
||||||
│ → Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ... │
|
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||||
│ → {"id":1, "nick":"alice"} │
|
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||||
│ │
|
│ (Session created with bcrypt-hashed password) │
|
||||||
│ 2. POST /api/v1/messages │
|
|
||||||
│ {"command":"PASS","body":["s3cret!!"]} │
|
|
||||||
│ → {"status":"ok"} │
|
|
||||||
│ (Password set via IRC PASS command) │
|
|
||||||
│ │
|
│ │
|
||||||
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
|
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
|
||||||
│ │
|
│ │
|
||||||
└────────────────────────────────────────────────────────────┘
|
│ (From another device, while session is still active) │
|
||||||
|
|
||||||
┌─ Client B (another device, while session is still active) ┐
|
|
||||||
│ │
|
│ │
|
||||||
│ 3. POST /api/v1/login │
|
│ 2. POST /api/v1/login │
|
||||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||||
│ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ... │
|
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
|
||||||
│ → {"id":1, "nick":"alice"} │
|
|
||||||
│ (New client added to existing session — channels │
|
│ (New client added to existing session — channels │
|
||||||
│ and message queues are preserved.) │
|
│ and message queues are preserved. If all clients │
|
||||||
|
│ have logged out, session no longer exists.) │
|
||||||
│ │
|
│ │
|
||||||
└────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -528,7 +523,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). |
|
| `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.** |
|
| `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. |
|
| `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:**
|
**Important invariants:**
|
||||||
|
|
||||||
@@ -734,35 +729,6 @@ Change the user's nickname.
|
|||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.1.2
|
**IRC reference:** RFC 1459 §4.1.2
|
||||||
|
|
||||||
#### PASS — Set Session Password
|
|
||||||
|
|
||||||
Set a password on the current session, enabling multi-client login via
|
|
||||||
`POST /api/v1/login`. The password is hashed with bcrypt and stored
|
|
||||||
server-side.
|
|
||||||
|
|
||||||
**C2S:**
|
|
||||||
```json
|
|
||||||
{"command": "PASS", "body": ["mypassword"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
|
|
||||||
- `body[0]` is the password. Must be at least 8 characters.
|
|
||||||
- On success, the server responds with `{"status": "ok"}`.
|
|
||||||
- If the password is too short or missing, the server sends
|
|
||||||
ERR_NEEDMOREPARAMS (461) via the message queue.
|
|
||||||
- Calling PASS again overwrites the previous password.
|
|
||||||
- Once a password is set, `POST /api/v1/login` can be used with the nick
|
|
||||||
and password to create additional clients on the same session.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{"status": "ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
|
|
||||||
session password management.
|
|
||||||
|
|
||||||
#### TOPIC — Set Channel Topic
|
#### TOPIC — Set Channel Topic
|
||||||
|
|
||||||
Set or change a channel's topic.
|
Set or change a channel's topic.
|
||||||
@@ -829,7 +795,7 @@ Destroy the session and disconnect from the server.
|
|||||||
quitting user. The quitting user does **not** receive their own QUIT.
|
quitting user. The quitting user does **not** receive their own QUIT.
|
||||||
- The user is removed from all channels.
|
- The user is removed from all channels.
|
||||||
- Empty channels are deleted (ephemeral).
|
- Empty channels are deleted (ephemeral).
|
||||||
- The user's session is destroyed — the auth cookie is invalidated, the nick
|
- The user's session is destroyed — the auth token is invalidated, the nick
|
||||||
is released.
|
is released.
|
||||||
- Subsequent requests with the old token return HTTP 401.
|
- Subsequent requests with the old token return HTTP 401.
|
||||||
|
|
||||||
@@ -1047,13 +1013,12 @@ carries IRC-style parameters (e.g., channel name, target nick).
|
|||||||
Inspired by IRC, simplified:
|
Inspired by IRC, simplified:
|
||||||
|
|
||||||
| Mode | Name | Meaning |
|
| Mode | Name | Meaning |
|
||||||
|------|----------------|---------|
|
|------|--------------|---------|
|
||||||
| `+i` | Invite-only | Only invited users can join |
|
| `+i` | Invite-only | Only invited users can join |
|
||||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||||
| `+s` | Secret | Channel hidden from LIST response |
|
| `+s` | Secret | Channel hidden from LIST response |
|
||||||
| `+t` | Topic lock | Only operators can change the topic |
|
| `+t` | Topic lock | Only operators can change the topic |
|
||||||
| `+n` | No external | Only channel members can send messages to the channel |
|
| `+n` | No external | Only channel members can send messages to the channel |
|
||||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
|
|
||||||
|
|
||||||
**User channel modes (set per-user per-channel):**
|
**User channel modes (set per-user per-channel):**
|
||||||
|
|
||||||
@@ -1064,64 +1029,14 @@ Inspired by IRC, simplified:
|
|||||||
|
|
||||||
**Status:** Channel modes are defined but not yet enforced. The `modes` column
|
**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.
|
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
All endpoints accept and return `application/json`. Authenticated endpoints
|
All endpoints accept and return `application/json`. Authenticated endpoints
|
||||||
require the `neoirc_auth` cookie, which is set automatically by
|
require `Authorization: Bearer <token>` header. The token is obtained from
|
||||||
`POST /api/v1/session` and `POST /api/v1/login`.
|
`POST /api/v1/session`.
|
||||||
|
|
||||||
All API responses include appropriate HTTP status codes. Error responses have
|
All API responses include appropriate HTTP status codes. Error responses have
|
||||||
the format:
|
the format:
|
||||||
@@ -1150,18 +1065,11 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
|||||||
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||||
|
|
||||||
**Response:** `201 Created`
|
**Response:** `201 Created`
|
||||||
|
|
||||||
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
|
|
||||||
The JSON body does **not** include the token.
|
|
||||||
|
|
||||||
```
|
|
||||||
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"nick": "alice"
|
"nick": "alice",
|
||||||
|
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1169,16 +1077,7 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|
|||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `id` | integer | Server-assigned user ID |
|
| `id` | integer | Server-assigned user ID |
|
||||||
| `nick` | string | Confirmed nick (always matches request on success) |
|
| `nick` | string | Confirmed nick (always matches request on success) |
|
||||||
|
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
|
||||||
**Cookie properties:**
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|------------|-------|
|
|
||||||
| `Name` | `neoirc_auth` |
|
|
||||||
| `HttpOnly` | `true` (not accessible from JavaScript) |
|
|
||||||
| `SameSite` | `Strict` (prevents CSRF) |
|
|
||||||
| `Secure` | `true` when behind TLS |
|
|
||||||
| `Path` | `/` |
|
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
@@ -1191,18 +1090,66 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|
|||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
# Use -c to save cookies, -b to send them
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
|
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||||
|
echo $TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/register — Register Account
|
||||||
|
|
||||||
|
Create a new user session with a password. The password is hashed
|
||||||
|
with bcrypt and stored server-side. The password enables login from
|
||||||
|
additional clients via `POST /api/v1/login` while the session
|
||||||
|
remains active.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{"nick": "alice", "password": "mypassword"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
|------------|--------|----------|-------------|
|
||||||
|
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||||
|
| `password` | string | Yes | Minimum 8 characters |
|
||||||
|
|
||||||
|
**Response:** `201 Created`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nick": "alice",
|
||||||
|
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `id` | integer | Server-assigned user ID |
|
||||||
|
| `nick` | string | Confirmed nick |
|
||||||
|
| `token` | string | 64-character hex auth token |
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | Error | When |
|
||||||
|
|--------|-------|------|
|
||||||
|
| 400 | `invalid nick format` | Nick doesn't match allowed format |
|
||||||
|
| 400 | `password must be at least 8 characters` | Password too short |
|
||||||
|
| 409 | `nick already taken` | Another active session holds this nick |
|
||||||
|
|
||||||
|
**curl example:**
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
||||||
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/v1/login — Login to Account
|
### POST /api/v1/login — Login to Account
|
||||||
|
|
||||||
Authenticate with a nick and password (set via the PASS IRC command). Creates a
|
Authenticate with a previously registered nick and password. Creates a new
|
||||||
new client for the existing session, preserving channel memberships and message
|
client token for the existing session, preserving channel memberships and
|
||||||
queues. This is how multi-client access works: each login adds a new client to
|
message queues. This is how multi-client access works for registered accounts:
|
||||||
the session with its own auth cookie and message delivery queue.
|
each login adds a new client to the session.
|
||||||
|
|
||||||
On successful login, the server enqueues MOTD messages and synthetic channel
|
On successful login, the server enqueues MOTD messages and synthetic channel
|
||||||
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
|
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
|
||||||
@@ -1215,37 +1162,37 @@ new client's queue, so the client can immediately restore its UI state.
|
|||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
| Field | Type | Required | Constraints |
|
||||||
|------------|--------|----------|-------------|
|
|------------|--------|----------|-------------|
|
||||||
| `nick` | string | Yes | Must match an active session with a password set |
|
| `nick` | string | Yes | Must match a registered account |
|
||||||
| `password` | string | Yes | Must match the session's password |
|
| `password` | string | Yes | Must match the account's password |
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
|
|
||||||
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"nick": "alice"
|
"nick": "alice",
|
||||||
|
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `id` | integer | Session ID |
|
| `id` | integer | Session ID (same as when registered) |
|
||||||
| `nick` | string | Current nick |
|
| `nick` | string | Current nick |
|
||||||
|
| `token` | string | New 64-character hex auth token for this client |
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `nick and password required` | Missing nick or password |
|
| 400 | `nick and password required` | Missing nick or password |
|
||||||
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
|
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
|
||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"alice","password":"mypassword"}'
|
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
||||||
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/v1/state — Get Session State
|
### GET /api/v1/state — Get Session State
|
||||||
@@ -1289,13 +1236,13 @@ Each channel object:
|
|||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8080/api/v1/state \
|
curl -s http://localhost:8080/api/v1/state \
|
||||||
-b cookies.txt | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reconnect with channel state initialization:**
|
**Reconnect with channel state initialization:**
|
||||||
```bash
|
```bash
|
||||||
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
||||||
-b cookies.txt | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
@@ -1355,12 +1302,14 @@ real-time endpoint — clients call it in a loop.
|
|||||||
|
|
||||||
**curl example (immediate):**
|
**curl example (immediate):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
|
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**curl example (long-poll, 15s):**
|
**curl example (long-poll, 15s):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
|
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/v1/messages — Send Command
|
### POST /api/v1/messages — Send Command
|
||||||
@@ -1387,7 +1336,6 @@ reference with all required and optional fields.
|
|||||||
| `JOIN` | `to` | | 200 OK |
|
| `JOIN` | `to` | | 200 OK |
|
||||||
| `PART` | `to` | `body` | 200 OK |
|
| `PART` | `to` | `body` | 200 OK |
|
||||||
| `NICK` | `body` | | 200 OK |
|
| `NICK` | `body` | | 200 OK |
|
||||||
| `PASS` | `body` | | 200 OK |
|
|
||||||
| `TOPIC` | `to`, `body` | | 200 OK |
|
| `TOPIC` | `to`, `body` | | 200 OK |
|
||||||
| `MODE` | `to` | | 200 OK |
|
| `MODE` | `to` | | 200 OK |
|
||||||
| `NAMES` | `to` | | 200 OK |
|
| `NAMES` | `to` | | 200 OK |
|
||||||
@@ -1402,14 +1350,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
|||||||
are delivered as **numeric replies** through the message queue (see
|
are delivered as **numeric replies** through the message queue (see
|
||||||
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
||||||
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
||||||
auth cookies (401), and server errors (500).
|
auth tokens (401), and server errors (500).
|
||||||
|
|
||||||
**HTTP errors (transport-level only):**
|
**HTTP errors (transport-level only):**
|
||||||
|
|
||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `invalid request` | Malformed JSON or empty command |
|
| 400 | `invalid request` | Malformed JSON or empty command |
|
||||||
| 401 | `unauthorized` | Missing or invalid auth cookie |
|
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||||
| 500 | `internal error` | Server-side failure |
|
| 500 | `internal error` | Server-side failure |
|
||||||
|
|
||||||
**IRC numeric error replies (delivered via message queue):**
|
**IRC numeric error replies (delivered via message queue):**
|
||||||
@@ -1500,11 +1448,11 @@ events). Event messages are delivered via the live queue only.
|
|||||||
```bash
|
```bash
|
||||||
# Latest 50 messages in #general
|
# Latest 50 messages in #general
|
||||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
||||||
-b cookies.txt | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
|
||||||
# Older messages (pagination)
|
# Older messages (pagination)
|
||||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
||||||
-b cookies.txt | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/v1/channels — List Channels
|
### GET /api/v1/channels — List Channels
|
||||||
@@ -1535,22 +1483,18 @@ List members of a channel. The `{name}` parameter is the channel name
|
|||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8080/api/v1/channels/general/members \
|
curl -s http://localhost:8080/api/v1/channels/general/members \
|
||||||
-b cookies.txt | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/v1/logout — Logout
|
### POST /api/v1/logout — Logout
|
||||||
|
|
||||||
Destroy the current client's session cookie and server-side client record.
|
Destroy the current client's auth token. If no other clients remain on the
|
||||||
If no other clients remain on the session, the user is fully cleaned up:
|
session, the user is fully cleaned up: parted from all channels (with QUIT
|
||||||
parted from all channels (with QUIT broadcast to members), session deleted,
|
broadcast to members), session deleted, nick released.
|
||||||
nick released. The auth cookie is cleared in the response.
|
|
||||||
|
|
||||||
**Request:** No body. Requires auth cookie.
|
**Request:** No body. Requires auth.
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
|
|
||||||
The response clears the `neoirc_auth` cookie.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"status": "ok"}
|
{"status": "ok"}
|
||||||
```
|
```
|
||||||
@@ -1559,11 +1503,12 @@ The response clears the `neoirc_auth` cookie.
|
|||||||
|
|
||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 401 | `unauthorized` | Missing or invalid auth cookie |
|
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
|
curl -s -X POST http://localhost:8080/api/v1/logout \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/v1/users/me — Current User Info
|
### GET /api/v1/users/me — Current User Info
|
||||||
@@ -1587,7 +1532,7 @@ Return the current user's session state. This is an alias for
|
|||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8080/api/v1/users/me \
|
curl -s http://localhost:8080/api/v1/users/me \
|
||||||
-b cookies.txt | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/v1/server — Server Info
|
### GET /api/v1/server — Server Info
|
||||||
@@ -1832,21 +1777,20 @@ authenticity.
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
|
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
|
||||||
entropy). Tokens are hashed (SHA-256) before storage and validated on every
|
Tokens are hashed (SHA-256) before storage and validated on every request.
|
||||||
request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict
|
|
||||||
(CSRF protection), and Secure when behind TLS.
|
|
||||||
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
|
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
|
||||||
password, instant access. The auth cookie is the sole credential.
|
password, instant access. The token is the sole credential.
|
||||||
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
|
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
|
||||||
password on the session. `POST /api/v1/login` authenticates against the
|
(minimum 8 characters). The password is hashed with bcrypt at the default
|
||||||
stored hash and issues a new client cookie.
|
cost factor and stored alongside the session. `POST /api/v1/login`
|
||||||
|
authenticates against the stored hash and issues a new client token.
|
||||||
- **Password security**: Passwords are never stored in plain text. bcrypt
|
- **Password security**: Passwords are never stored in plain text. bcrypt
|
||||||
handles salting and key stretching automatically. Sessions without a
|
handles salting and key stretching automatically. Anonymous sessions have
|
||||||
password cannot be logged into via `/login`.
|
an empty `password_hash` and cannot be logged into via `/login`.
|
||||||
- **Cookie security**: Auth cookies should only be transmitted over HTTPS in
|
- **Token security**: Tokens should be treated like session cookies. Transmit
|
||||||
production. If a cookie is compromised, the attacker has full access to the
|
only over HTTPS in production. If a token is compromised, the attacker has
|
||||||
session until QUIT or expiry.
|
full access to the session until QUIT or expiry.
|
||||||
|
|
||||||
### Message Integrity
|
### Message Integrity
|
||||||
|
|
||||||
@@ -1883,10 +1827,8 @@ authenticity.
|
|||||||
- **HTTPS is strongly recommended** for production deployments. The server
|
- **HTTPS is strongly recommended** for production deployments. The server
|
||||||
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
|
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
|
||||||
termination.
|
termination.
|
||||||
- **CORS**: The server allows all origins with credentials
|
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||||
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
|
Restrict this in production via reverse proxy configuration if needed.
|
||||||
This enables cookie-based auth from cross-origin clients. Restrict origins
|
|
||||||
in production via reverse proxy configuration if needed.
|
|
||||||
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
||||||
responses, restricting resource loading to same-origin and disabling
|
responses, restricting resource loading to same-origin and disabling
|
||||||
dangerous features (object embeds, framing, base tag injection). The
|
dangerous features (object embeds, framing, base tag injection). The
|
||||||
@@ -2076,7 +2018,7 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
|||||||
- **Client output queue entries**: Pruned automatically when older than
|
- **Client output queue entries**: Pruned automatically when older than
|
||||||
`QUEUE_MAX_AGE` (default 30 days).
|
`QUEUE_MAX_AGE` (default 30 days).
|
||||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||||
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
|
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
|
||||||
or when the last client logs out (`POST /api/v1/logout` with no remaining
|
or when the last client logs out (`POST /api/v1/logout` with no remaining
|
||||||
clients triggers session cleanup). There is no distinction between session
|
clients triggers session cleanup). There is no distinction between session
|
||||||
types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
||||||
@@ -2237,59 +2179,68 @@ A complete client needs only four HTTP calls:
|
|||||||
### Step-by-Step with curl
|
### Step-by-Step with curl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1a. Create a session (cookie saved automatically with -c)
|
# 1a. Create an anonymous session (no account)
|
||||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"testuser"}'
|
-d '{"nick":"testuser"}' | jq -r .token)
|
||||||
|
|
||||||
# 1b. Optionally set a password for multi-client access
|
# 1b. Or register an account (multi-client support)
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"PASS","body":["mypassword"]}'
|
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
||||||
|
|
||||||
# 1c. Login from another device (saves new cookie)
|
# 1c. Or login to an existing account
|
||||||
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
|
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"testuser","password":"mypassword"}'
|
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
||||||
|
|
||||||
# 2. Join a channel
|
# 2. Join a channel
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"JOIN","to":"#general"}'
|
-d '{"command":"JOIN","to":"#general"}'
|
||||||
|
|
||||||
# 3. Send a message
|
# 3. Send a message
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
|
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
|
||||||
|
|
||||||
# 4. Poll for messages (one-shot)
|
# 4. Poll for messages (one-shot)
|
||||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
|
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
|
||||||
# 5. Long-poll (blocks up to 15s waiting for messages)
|
# 5. Long-poll (blocks up to 15s waiting for messages)
|
||||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
|
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
|
||||||
# 6. Send a DM
|
# 6. Send a DM
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
|
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
|
||||||
|
|
||||||
# 7. Change nick
|
# 7. Change nick
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"NICK","body":["newnick"]}'
|
-d '{"command":"NICK","body":["newnick"]}'
|
||||||
|
|
||||||
# 8. Set channel topic
|
# 8. Set channel topic
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
|
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
|
||||||
|
|
||||||
# 9. Leave a channel
|
# 9. Leave a channel
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
|
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
|
||||||
|
|
||||||
# 10. Disconnect
|
# 10. Disconnect
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"command":"QUIT","body":["leaving"]}'
|
-d '{"command":"QUIT","body":["leaving"]}'
|
||||||
```
|
```
|
||||||
@@ -2299,25 +2250,27 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
|||||||
The key to real-time messaging is the poll loop. Here's the pattern:
|
The key to real-time messaging is the poll loop. Here's the pattern:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Python example — using requests.Session for automatic cookie handling
|
# Python example
|
||||||
import requests, json, time
|
import requests, json
|
||||||
|
|
||||||
BASE = "http://localhost:8080/api/v1"
|
BASE = "http://localhost:8080/api/v1"
|
||||||
session = requests.Session() # Manages cookies automatically
|
token = None
|
||||||
last_id = 0
|
last_id = 0
|
||||||
|
|
||||||
# Create session (cookie set automatically via Set-Cookie header)
|
# Create session
|
||||||
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
|
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
|
||||||
print(f"Session: {resp.json()}")
|
token = resp.json()["token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
# Join channel
|
# Join channel
|
||||||
session.post(f"{BASE}/messages",
|
requests.post(f"{BASE}/messages", headers=headers,
|
||||||
json={"command": "JOIN", "to": "#general"})
|
json={"command": "JOIN", "to": "#general"})
|
||||||
|
|
||||||
# Poll loop
|
# Poll loop
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
resp = session.get(f"{BASE}/messages",
|
resp = requests.get(f"{BASE}/messages",
|
||||||
|
headers=headers,
|
||||||
params={"after": last_id, "timeout": 15},
|
params={"after": last_id, "timeout": 15},
|
||||||
timeout=20) # HTTP timeout > long-poll timeout
|
timeout=20) # HTTP timeout > long-poll timeout
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -2334,14 +2287,14 @@ while True:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// JavaScript/browser example — cookies sent automatically
|
// JavaScript/browser example
|
||||||
async function pollLoop() {
|
async function pollLoop(token) {
|
||||||
let lastId = 0;
|
let lastId = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/v1/messages?after=${lastId}&timeout=15`,
|
`/api/v1/messages?after=${lastId}&timeout=15`,
|
||||||
{credentials: 'same-origin'} // Include cookies
|
{headers: {'Authorization': `Bearer ${token}`}}
|
||||||
);
|
);
|
||||||
if (resp.status === 401) { /* session expired */ break; }
|
if (resp.status === 401) { /* session expired */ break; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -2373,8 +2326,8 @@ Clients should handle these message commands from the queue:
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
|
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
|
||||||
re-login (if a password was set).
|
re-login (registered account).
|
||||||
- **HTTP 404**: Channel or user not found.
|
- **HTTP 404**: Channel or user not found.
|
||||||
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||||
NICK change).
|
NICK change).
|
||||||
@@ -2394,11 +2347,10 @@ Clients should handle these message commands from the queue:
|
|||||||
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
||||||
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
||||||
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
||||||
5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
|
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
|
||||||
invalid. For sessions without a password, create a new session. For
|
For anonymous sessions, create a new session. For registered accounts,
|
||||||
sessions with a password set (via PASS command), log in again via
|
log in again via `POST /api/v1/login` to get a fresh token on the same
|
||||||
`POST /api/v1/login` to get a fresh cookie on the same session. If it
|
session. If it fails with a network error, retry with backoff.
|
||||||
fails with a network error, retry with backoff.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2557,10 +2509,8 @@ creating one session pays once and keeps their session.
|
|||||||
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
|
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
|
||||||
- [ ] **Max message size enforcement** — reject oversized messages
|
- [ ] **Max message size enforcement** — reject oversized messages
|
||||||
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
||||||
- [x] **Multi-client sessions** — set a password via PASS command, then
|
- [ ] **Multi-client sessions** — add client to existing session
|
||||||
login from additional devices via `POST /api/v1/login`
|
(share nick across devices)
|
||||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
|
||||||
all API authentication
|
|
||||||
|
|
||||||
### Future (1.0+)
|
### Future (1.0+)
|
||||||
|
|
||||||
@@ -2673,12 +2623,13 @@ neoirc/
|
|||||||
build a working IRC-style TUI client against this API in an afternoon, the
|
build a working IRC-style TUI client against this API in an afternoon, the
|
||||||
API is too complex.
|
API is too complex.
|
||||||
|
|
||||||
2. **Passwords optional** — anonymous sessions are instant: pick a nick and
|
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
|
||||||
talk. No registration, no email verification. The cost of entry is a
|
talk. No registration, no email verification. The cost of entry is a
|
||||||
hashcash proof, not bureaucracy. For users who want multi-client access
|
hashcash proof, not bureaucracy. For users who want multi-client access
|
||||||
(multiple devices sharing one session), the PASS command sets a password
|
(multiple devices sharing one session), optional account registration
|
||||||
on the session — but it's never required. Identity verification at the
|
with password is available — but never required. Identity
|
||||||
message layer uses cryptographic signing, independent of password status.
|
verification at the message layer uses cryptographic signing,
|
||||||
|
independent of account status.
|
||||||
|
|
||||||
3. **IRC semantics over HTTP** — command names and numeric codes from
|
3. **IRC semantics over HTTP** — command names and numeric codes from
|
||||||
RFC 1459/2812. If you've built an IRC client or bot, you already know the
|
RFC 1459/2812. If you've built an IRC client or bot, you already know the
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,19 +28,16 @@ var errHTTP = errors.New("HTTP error")
|
|||||||
// Client wraps HTTP calls to the neoirc server API.
|
// Client wraps HTTP calls to the neoirc server API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
Token string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new API client with a cookie jar
|
// NewClient creates a new API client.
|
||||||
// for automatic auth cookie management.
|
|
||||||
func NewClient(baseURL string) *Client {
|
func NewClient(baseURL string) *Client {
|
||||||
jar, _ := cookiejar.New(nil)
|
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
||||||
|
|
||||||
return &Client{
|
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
||||||
Timeout: httpTimeout,
|
Timeout: httpTimeout,
|
||||||
Jar: jar,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +79,8 @@ func (client *Client) CreateSession(
|
|||||||
return nil, fmt.Errorf("decode session: %w", err)
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.Token = resp.Token
|
||||||
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +121,6 @@ func (client *Client) PollMessages(
|
|||||||
Timeout: time.Duration(
|
Timeout: time.Duration(
|
||||||
timeout+pollExtraTime,
|
timeout+pollExtraTime,
|
||||||
) * time.Second,
|
) * time.Second,
|
||||||
Jar: client.HTTPClient.Jar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
@@ -148,6 +145,10 @@ func (client *Client) PollMessages(
|
|||||||
return nil, fmt.Errorf("new request: %w", err)
|
return nil, fmt.Errorf("new request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.Header.Set(
|
||||||
|
"Authorization", "Bearer "+client.Token,
|
||||||
|
)
|
||||||
|
|
||||||
resp, err := pollClient.Do(request)
|
resp, err := pollClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("poll request: %w", err)
|
return nil, fmt.Errorf("poll request: %w", err)
|
||||||
@@ -303,6 +304,12 @@ func (client *Client) do(
|
|||||||
"Content-Type", "application/json",
|
"Content-Type", "application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if client.Token != "" {
|
||||||
|
request.Header.Set(
|
||||||
|
"Authorization", "Bearer "+client.Token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.HTTPClient.Do(request)
|
resp, err := client.HTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
// leading zero bits.
|
// leading zero bits.
|
||||||
func hasLeadingZeroBits(
|
func hasLeadingZeroBits(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type SessionRequest struct {
|
|||||||
type SessionResponse struct {
|
type SessionResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateResponse is the response from GET /api/v1/state.
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
|
|||||||
@@ -16,28 +16,80 @@ var errNoPassword = errors.New(
|
|||||||
"account has no password set",
|
"account has no password set",
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetPassword sets a bcrypt-hashed password on a session,
|
// RegisterUser creates a session with a hashed password
|
||||||
// enabling multi-client login via POST /api/v1/login.
|
// and returns session ID, client ID, and token.
|
||||||
func (database *Database) SetPassword(
|
func (database *Database) RegisterUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
nick, password string,
|
||||||
password string,
|
) (int64, int64, string, error) {
|
||||||
) error {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword(
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
[]byte(password), bcryptCost,
|
[]byte(password), bcryptCost,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash password: %w", err)
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"hash password: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = database.conn.ExecContext(ctx,
|
sessionUUID := uuid.New().String()
|
||||||
"UPDATE sessions SET password_hash = ? WHERE id = ?",
|
clientUUID := uuid.New().String()
|
||||||
string(hash), sessionID)
|
|
||||||
|
token, err := generateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set password: %w", err)
|
return 0, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
now := time.Now()
|
||||||
|
|
||||||
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"begin tx: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := transaction.ExecContext(ctx,
|
||||||
|
`INSERT INTO sessions
|
||||||
|
(uuid, nick, password_hash,
|
||||||
|
created_at, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
sessionUUID, nick, string(hash), now, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"create session: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
|
`INSERT INTO clients
|
||||||
|
(uuid, session_id, token,
|
||||||
|
created_at, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"create client: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, _ := clientRes.LastInsertId()
|
||||||
|
|
||||||
|
err = transaction.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"commit registration: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID, clientID, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginUser verifies a nick/password and creates a new
|
// LoginUser verifies a nick/password and creates a new
|
||||||
|
|||||||
@@ -6,65 +6,63 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetPassword(t *testing.T) {
|
func TestRegisterUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
sessionID, clientID, token, err :=
|
||||||
database.CreateSession(ctx, "passuser")
|
database.RegisterUser(ctx, "reguser", "password123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.SetPassword(
|
if sessionID == 0 || clientID == 0 || token == "" {
|
||||||
ctx, sessionID, "password123",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we can now log in with the password.
|
|
||||||
loginSID, loginCID, loginToken, err :=
|
|
||||||
database.LoginUser(ctx, "passuser", "password123")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loginSID == 0 || loginCID == 0 || loginToken == "" {
|
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify session works via token lookup.
|
||||||
|
sid, cid, nick, err :=
|
||||||
|
database.GetSessionByToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetPasswordThenWrongLogin(t *testing.T) {
|
if sid != sessionID || cid != clientID {
|
||||||
|
t.Fatal("session/client id mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if nick != "reguser" {
|
||||||
|
t.Fatalf("expected reguser, got %s", nick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
regSID, regCID, regToken, err :=
|
||||||
database.CreateSession(ctx, "wrongpw")
|
database.RegisterUser(ctx, "dupnick", "password123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.SetPassword(
|
_ = regSID
|
||||||
ctx, sessionID, "correctpass",
|
_ = regCID
|
||||||
)
|
_ = regToken
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
dupSID, dupCID, dupToken, dupErr :=
|
||||||
|
database.RegisterUser(ctx, "dupnick", "other12345")
|
||||||
|
if dupErr == nil {
|
||||||
|
t.Fatal("expected error for duplicate nick")
|
||||||
}
|
}
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
_ = dupSID
|
||||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
_ = dupCID
|
||||||
if loginErr == nil {
|
_ = dupToken
|
||||||
t.Fatal("expected error for wrong password")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = loginSID
|
|
||||||
_ = loginCID
|
|
||||||
_ = loginToken
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginUser(t *testing.T) {
|
func TestLoginUser(t *testing.T) {
|
||||||
@@ -73,26 +71,23 @@ func TestLoginUser(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
regSID, regCID, regToken, err :=
|
||||||
database.CreateSession(ctx, "loginuser")
|
database.RegisterUser(ctx, "loginuser", "mypassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.SetPassword(
|
_ = regSID
|
||||||
ctx, sessionID, "mypassword",
|
_ = regCID
|
||||||
)
|
_ = regToken
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loginSID, loginCID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
database.LoginUser(ctx, "loginuser", "mypassword")
|
database.LoginUser(ctx, "loginuser", "mypassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginSID == 0 || loginCID == 0 || token == "" {
|
if sessionID == 0 || clientID == 0 || token == "" {
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +103,33 @@ func TestLoginUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginUserWrongPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
regSID, regCID, regToken, err :=
|
||||||
|
database.RegisterUser(ctx, "wrongpw", "correctpass")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = regSID
|
||||||
|
_ = regCID
|
||||||
|
_ = regToken
|
||||||
|
|
||||||
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
|
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||||
|
if loginErr == nil {
|
||||||
|
t.Fatal("expected error for wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = loginSID
|
||||||
|
_ = loginCID
|
||||||
|
_ = loginToken
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginUserNoPassword(t *testing.T) {
|
func TestLoginUserNoPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1305,110 +1305,3 @@ func (database *Database) GetQueueEntryCount(
|
|||||||
|
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelHashcashBits returns the hashcash difficulty
|
|
||||||
// requirement for a channel. Returns 0 if not set.
|
|
||||||
func (database *Database) GetChannelHashcashBits(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (int, error) {
|
|
||||||
var bits int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT hashcash_bits FROM channels WHERE id = ?",
|
|
||||||
channelID,
|
|
||||||
).Scan(&bits)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get channel hashcash bits: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return bits, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelHashcashBits sets the hashcash difficulty
|
|
||||||
// requirement for a channel. A value of 0 disables the
|
|
||||||
// requirement.
|
|
||||||
func (database *Database) SetChannelHashcashBits(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
bits int,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET hashcash_bits = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
bits, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel hashcash bits: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSpentHashcash stores a spent hashcash stamp hash
|
|
||||||
// for replay prevention.
|
|
||||||
func (database *Database) RecordSpentHashcash(
|
|
||||||
ctx context.Context,
|
|
||||||
stampHash string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT OR IGNORE INTO spent_hashcash
|
|
||||||
(stamp_hash, created_at)
|
|
||||||
VALUES (?, ?)`,
|
|
||||||
stampHash, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"record spent hashcash: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsHashcashSpent checks whether a hashcash stamp hash
|
|
||||||
// has already been used.
|
|
||||||
func (database *Database) IsHashcashSpent(
|
|
||||||
ctx context.Context,
|
|
||||||
stampHash string,
|
|
||||||
) (bool, error) {
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM spent_hashcash
|
|
||||||
WHERE stamp_hash = ?`,
|
|
||||||
stampHash,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check spent hashcash: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneSpentHashcash deletes spent hashcash tokens older
|
|
||||||
// than the cutoff and returns the number of rows removed.
|
|
||||||
func (database *Database) PruneSpentHashcash(
|
|
||||||
ctx context.Context,
|
|
||||||
cutoff time.Time,
|
|
||||||
) (int64, error) {
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
|
||||||
"DELETE FROM spent_hashcash WHERE created_at < ?",
|
|
||||||
cutoff,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"prune spent hashcash: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted, _ := res.RowsAffected()
|
|
||||||
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
topic TEXT NOT NULL DEFAULT '',
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||||
topic_set_at DATETIME,
|
topic_set_at DATETIME,
|
||||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -62,14 +61,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_to_id ON messages(msg_to, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
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
|
-- Per-client message queues for fan-out delivery
|
||||||
CREATE TABLE IF NOT EXISTS client_queues (
|
CREATE TABLE IF NOT EXISTS client_queues (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -12,16 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
errHashcashRequired = errors.New("hashcash required")
|
|
||||||
errHashcashReused = errors.New("hashcash reused")
|
|
||||||
)
|
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||||
)
|
)
|
||||||
@@ -36,7 +29,6 @@ const (
|
|||||||
defaultMaxBodySize = 4096
|
defaultMaxBodySize = 4096
|
||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
maxHistLimit = 500
|
maxHistLimit = 500
|
||||||
authCookieName = "neoirc_auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hdlr *Handlers) maxBodySize() int64 {
|
func (hdlr *Handlers) maxBodySize() int64 {
|
||||||
@@ -47,18 +39,23 @@ func (hdlr *Handlers) maxBodySize() int64 {
|
|||||||
return defaultMaxBodySize
|
return defaultMaxBodySize
|
||||||
}
|
}
|
||||||
|
|
||||||
// authSession extracts the session from the auth cookie.
|
// authSession extracts the session from the client token.
|
||||||
func (hdlr *Handlers) authSession(
|
func (hdlr *Handlers) authSession(
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
cookie, err := request.Cookie(authCookieName)
|
auth := request.Header.Get("Authorization")
|
||||||
if err != nil || cookie.Value == "" {
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return 0, 0, "", errUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if token == "" {
|
||||||
return 0, 0, "", errUnauthorized
|
return 0, 0, "", errUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, clientID, nick, err :=
|
sessionID, clientID, nick, err :=
|
||||||
hdlr.params.Database.GetSessionByToken(
|
hdlr.params.Database.GetSessionByToken(
|
||||||
request.Context(), cookie.Value,
|
request.Context(), token,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf("auth: %w", err)
|
return 0, 0, "", fmt.Errorf("auth: %w", err)
|
||||||
@@ -67,46 +64,6 @@ func (hdlr *Handlers) authSession(
|
|||||||
return sessionID, clientID, nick, nil
|
return sessionID, clientID, nick, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAuthCookie sets the authentication cookie on the
|
|
||||||
// response.
|
|
||||||
func (hdlr *Handlers) setAuthCookie(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
token string,
|
|
||||||
) {
|
|
||||||
secure := request.TLS != nil ||
|
|
||||||
request.Header.Get("X-Forwarded-Proto") == "https"
|
|
||||||
|
|
||||||
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
|
||||||
Name: authCookieName,
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: secure,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearAuthCookie removes the authentication cookie from
|
|
||||||
// the client.
|
|
||||||
func (hdlr *Handlers) clearAuthCookie(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
secure := request.TLS != nil ||
|
|
||||||
request.Header.Get("X-Forwarded-Proto") == "https"
|
|
||||||
|
|
||||||
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
|
||||||
Name: authCookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: secure,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
MaxAge: -1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) requireAuth(
|
func (hdlr *Handlers) requireAuth(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -131,11 +88,10 @@ func (hdlr *Handlers) fanOut(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert message: %w", err)
|
return "", fmt.Errorf("insert message: %w", err)
|
||||||
@@ -161,11 +117,10 @@ func (hdlr *Handlers) fanOutSilent(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := hdlr.fanOut(
|
_, err := hdlr.fanOut(
|
||||||
request, command, from, target, body, meta, sessionIDs,
|
request, command, from, target, body, sessionIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -262,11 +217,10 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.setAuthCookie(writer, request, token)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
"token": token,
|
||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +294,7 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
[]string{
|
[]string{
|
||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
"CHANMODES=,,H," + "imnst",
|
"CHANMODES=,,," + "imnst",
|
||||||
"NETWORK=neoirc",
|
"NETWORK=neoirc",
|
||||||
"CASEMAPPING=ascii",
|
"CASEMAPPING=ascii",
|
||||||
},
|
},
|
||||||
@@ -871,7 +825,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
payload.Command, payload.To,
|
payload.Command, payload.To,
|
||||||
payload.Body, payload.Meta, bodyLines,
|
payload.Body, bodyLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,7 +836,6 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
@@ -895,7 +848,7 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, meta, bodyLines,
|
command, target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdJoin:
|
case irc.CmdJoin:
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
@@ -912,11 +865,6 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdPass:
|
|
||||||
hdlr.handlePass(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdTopic:
|
case irc.CmdTopic:
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -1001,7 +949,6 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
@@ -1039,7 +986,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
hdlr.handleChannelMsg(
|
hdlr.handleChannelMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, meta,
|
command, target, body,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1048,7 +995,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
hdlr.handleDirectMsg(
|
hdlr.handleDirectMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, meta,
|
command, target, body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,7 +1026,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
) {
|
) {
|
||||||
chID, err := hdlr.params.Database.GetChannelByName(
|
chID, err := hdlr.params.Database.GetChannelByName(
|
||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
@@ -1120,180 +1066,16 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashcashErr := hdlr.validateChannelHashcash(
|
|
||||||
request, clientID, sessionID,
|
|
||||||
writer, nick, target, body, meta, chID,
|
|
||||||
)
|
|
||||||
if hashcashErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.sendChannelMsg(
|
hdlr.sendChannelMsg(
|
||||||
writer, request, command, nick, target,
|
writer, request, command, nick, target, body, chID,
|
||||||
body, meta, 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(
|
func (hdlr *Handlers) sendChannelMsg(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
command, nick, target string,
|
command, nick, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
||||||
@@ -1313,7 +1095,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgUUID, err := hdlr.fanOut(
|
msgUUID, err := hdlr.fanOut(
|
||||||
request, command, nick, target, body, meta, memberIDs,
|
request, command, nick, target, body, memberIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error("send message failed", "error", err)
|
hdlr.log.Error("send message failed", "error", err)
|
||||||
@@ -1337,7 +1119,6 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
|
||||||
) {
|
) {
|
||||||
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
@@ -1362,7 +1143,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgUUID, err := hdlr.fanOut(
|
msgUUID, err := hdlr.fanOut(
|
||||||
request, command, nick, target, body, meta, recipients,
|
request, command, nick, target, body, recipients,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error("send dm failed", "error", err)
|
hdlr.log.Error("send dm failed", "error", err)
|
||||||
@@ -1473,7 +1254,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.deliverJoinNumerics(
|
hdlr.deliverJoinNumerics(
|
||||||
@@ -1643,7 +1424,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = hdlr.params.Database.PartChannel(
|
err = hdlr.params.Database.PartChannel(
|
||||||
@@ -1923,7 +1704,7 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
@@ -2047,8 +1828,6 @@ func (hdlr *Handlers) handleQuit(
|
|||||||
request.Context(), sessionID,
|
request.Context(), sessionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.clearAuthCookie(writer, request)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"status": "quit"},
|
map[string]string{"status": "quit"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -2088,10 +1867,11 @@ func (hdlr *Handlers) handleMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = bodyLines
|
||||||
|
|
||||||
hdlr.handleChannelMode(
|
hdlr.handleChannelMode(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, channel,
|
sessionID, clientID, nick, channel,
|
||||||
bodyLines,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2100,7 +1880,6 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, channel string,
|
nick, channel string,
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
@@ -2117,47 +1896,10 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
return
|
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
|
// 324 RPL_CHANNELMODEIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||||
[]string{channel, modeStr}, "",
|
[]string{channel, "+n"}, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 329 RPL_CREATIONTIME
|
// 329 RPL_CREATIONTIME
|
||||||
@@ -2182,156 +1924,6 @@ func (hdlr *Handlers) queryChannelMode(
|
|||||||
http.StatusOK)
|
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.
|
// handleNames sends NAMES reply for a channel.
|
||||||
func (hdlr *Handlers) handleNames(
|
func (hdlr *Handlers) handleNames(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
@@ -2851,8 +2443,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.clearAuthCookie(writer, request)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"status": "ok"},
|
map[string]string{"status": "ok"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
"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/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
@@ -42,7 +41,6 @@ const (
|
|||||||
apiMessages = "/api/v1/messages"
|
apiMessages = "/api/v1/messages"
|
||||||
apiSession = "/api/v1/session"
|
apiSession = "/api/v1/session"
|
||||||
apiState = "/api/v1/state"
|
apiState = "/api/v1/state"
|
||||||
authCookieName = "neoirc_auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// testServer wraps a test HTTP server with helpers.
|
// testServer wraps a test HTTP server with helpers.
|
||||||
@@ -262,7 +260,7 @@ func doRequest(
|
|||||||
|
|
||||||
func doRequestAuth(
|
func doRequestAuth(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
method, url, cookie string,
|
method, url, token string,
|
||||||
body io.Reader,
|
body io.Reader,
|
||||||
) (*http.Response, error) {
|
) (*http.Response, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -280,11 +278,10 @@ func doRequestAuth(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cookie != "" {
|
if token != "" {
|
||||||
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
|
request.Header.Set(
|
||||||
Name: authCookieName,
|
"Authorization", "Bearer "+token,
|
||||||
Value: cookie,
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(request)
|
resp, err := http.DefaultClient.Do(request)
|
||||||
@@ -327,19 +324,17 @@ func (tserver *testServer) createSession(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain the body.
|
var result struct {
|
||||||
_, _ = io.ReadAll(resp.Body)
|
ID int64 `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
// Extract auth cookie from response.
|
|
||||||
for _, cookie := range resp.Cookies() {
|
|
||||||
if cookie.Name == authCookieName {
|
|
||||||
return cookie.Value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tserver.t.Fatal("no auth cookie in response")
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if decErr != nil {
|
||||||
|
tserver.t.Fatalf("decode session: %v", decErr)
|
||||||
|
}
|
||||||
|
|
||||||
return ""
|
return result.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tserver *testServer) sendCommand(
|
func (tserver *testServer) sendCommand(
|
||||||
@@ -496,10 +491,10 @@ func findNumeric(
|
|||||||
|
|
||||||
func TestCreateSessionValid(t *testing.T) {
|
func TestCreateSessionValid(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
cookie := tserver.createSession("alice")
|
token := tserver.createSession("alice")
|
||||||
|
|
||||||
if cookie == "" {
|
if token == "" {
|
||||||
t.Fatal("expected auth cookie")
|
t.Fatal("expected token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +616,7 @@ func TestCreateSessionMalformed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthNoCookie(t *testing.T) {
|
func TestAuthNoHeader(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
status, _ := tserver.getState("")
|
status, _ := tserver.getState("")
|
||||||
@@ -630,11 +625,11 @@ func TestAuthNoCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthBadCookie(t *testing.T) {
|
func TestAuthBadToken(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
status, _ := tserver.getState(
|
status, _ := tserver.getState(
|
||||||
"invalid-cookie-12345",
|
"invalid-token-12345",
|
||||||
)
|
)
|
||||||
if status != http.StatusUnauthorized {
|
if status != http.StatusUnauthorized {
|
||||||
t.Fatalf("expected 401, got %d", status)
|
t.Fatalf("expected 401, got %d", status)
|
||||||
@@ -1831,6 +1826,90 @@ func assertFieldGTE(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterValid(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "reguser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 201, got %d: %s",
|
||||||
|
resp.StatusCode, respBody,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
|
||||||
|
if result["token"] == nil || result["token"] == "" {
|
||||||
|
t.Fatal("expected token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result["nick"] != "reguser" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected reguser, got %v", result["nick"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterDuplicate(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "dupuser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
resp2, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp2.Body.Close() }()
|
||||||
|
|
||||||
|
if resp2.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409, got %d", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func postJSONExpectStatus(
|
func postJSONExpectStatus(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
tserver *testServer,
|
tserver *testServer,
|
||||||
@@ -1865,102 +1944,36 @@ func postJSONExpectStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPassCommand(t *testing.T) {
|
func TestRegisterShortPassword(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("passuser")
|
|
||||||
|
|
||||||
// Drain initial messages.
|
postJSONExpectStatus(
|
||||||
_, _ = tserver.pollMessages(token, 0)
|
t, tserver, "/api/v1/register",
|
||||||
|
map[string]string{
|
||||||
// Set password via PASS command.
|
"nick": "shortpw", "password": "short",
|
||||||
status, result := tserver.sendCommand(
|
|
||||||
token,
|
|
||||||
map[string]any{
|
|
||||||
commandKey: "PASS",
|
|
||||||
bodyKey: []string{"s3cure_pass"},
|
|
||||||
},
|
},
|
||||||
)
|
http.StatusBadRequest,
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 200, got %d: %v", status, result,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[statusKey] != "ok" {
|
func TestRegisterInvalidNick(t *testing.T) {
|
||||||
t.Fatalf(
|
|
||||||
"expected ok, got %v", result[statusKey],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPassCommandShortPassword(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("shortpw")
|
|
||||||
|
|
||||||
// Drain initial messages.
|
postJSONExpectStatus(
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
t, tserver, "/api/v1/register",
|
||||||
|
map[string]string{
|
||||||
// Try short password — should fail.
|
"nick": "bad nick!",
|
||||||
status, _ := tserver.sendCommand(
|
"password": "password123",
|
||||||
token,
|
|
||||||
map[string]any{
|
|
||||||
commandKey: "PASS",
|
|
||||||
bodyKey: []string{"short"},
|
|
||||||
},
|
},
|
||||||
|
http.StatusBadRequest,
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPassCommandEmpty(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
token := tserver.createSession("emptypw")
|
|
||||||
|
|
||||||
// Drain initial messages.
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Try empty password — should fail.
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token,
|
|
||||||
map[string]any{commandKey: "PASS"},
|
|
||||||
)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginValid(t *testing.T) {
|
func TestLoginValid(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
// Create session and set password via PASS command.
|
// Register first.
|
||||||
token := tserver.createSession("loginuser")
|
regBody, err := json.Marshal(map[string]string{
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "PASS",
|
|
||||||
bodyKey: []string{"password123"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Login with nick + password.
|
|
||||||
loginBody, err := json.Marshal(map[string]string{
|
|
||||||
"nick": "loginuser", "password": "password123",
|
"nick": "loginuser", "password": "password123",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1968,6 +1981,26 @@ func TestLoginValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := doRequest(
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(regBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// Login.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "loginuser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2, err := doRequest(
|
||||||
t,
|
t,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
tserver.url("/api/v1/login"),
|
tserver.url("/api/v1/login"),
|
||||||
@@ -1977,33 +2010,31 @@ func TestLoginValid(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp2.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp2.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp2.Body)
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected 200, got %d: %s",
|
"expected 200, got %d: %s",
|
||||||
resp.StatusCode, respBody,
|
resp2.StatusCode, respBody,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract auth cookie from login response.
|
var result map[string]any
|
||||||
var loginCookie string
|
|
||||||
|
|
||||||
for _, cookie := range resp.Cookies() {
|
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
||||||
if cookie.Name == authCookieName {
|
|
||||||
loginCookie = cookie.Value
|
|
||||||
|
|
||||||
break
|
if result["token"] == nil || result["token"] == "" {
|
||||||
}
|
t.Fatal("expected token in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginCookie == "" {
|
// Verify token works.
|
||||||
t.Fatal("expected auth cookie from login")
|
token, ok := result["token"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("token not a string")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify login cookie works for auth.
|
status, state := tserver.getState(token)
|
||||||
status, state := tserver.getState(loginCookie)
|
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
@@ -2019,22 +2050,49 @@ func TestLoginValid(t *testing.T) {
|
|||||||
func TestLoginWrongPassword(t *testing.T) {
|
func TestLoginWrongPassword(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
// Create session and set password via PASS command.
|
regBody, err := json.Marshal(map[string]string{
|
||||||
token := tserver.createSession("wrongpwuser")
|
"nick": "wrongpwuser", "password": "correctpass1",
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "PASS",
|
|
||||||
bodyKey: []string{"correctpass1"},
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
postJSONExpectStatus(
|
resp, err := doRequest(
|
||||||
t, tserver, "/api/v1/login",
|
t,
|
||||||
map[string]string{
|
http.MethodPost,
|
||||||
"nick": "wrongpwuser",
|
tserver.url("/api/v1/register"),
|
||||||
"password": "wrongpass12",
|
bytes.NewReader(regBody),
|
||||||
},
|
|
||||||
http.StatusUnauthorized,
|
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "wrongpwuser", "password": "wrongpass12",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp2.Body.Close() }()
|
||||||
|
|
||||||
|
if resp2.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 401, got %d", resp2.StatusCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginNonexistentUser(t *testing.T) {
|
func TestLoginNonexistentUser(t *testing.T) {
|
||||||
@@ -2050,74 +2108,13 @@ func TestLoginNonexistentUser(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionCookie(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
body, err := json.Marshal(
|
|
||||||
map[string]string{"nick": "cookietest"},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodPost,
|
|
||||||
tserver.url(apiSession),
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 201, got %d", resp.StatusCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify Set-Cookie header.
|
|
||||||
var authCookie *http.Cookie
|
|
||||||
|
|
||||||
for _, cookie := range resp.Cookies() {
|
|
||||||
if cookie.Name == authCookieName {
|
|
||||||
authCookie = cookie
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if authCookie == nil {
|
|
||||||
t.Fatal("expected neoirc_auth cookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !authCookie.HttpOnly {
|
|
||||||
t.Fatal("cookie should be HttpOnly")
|
|
||||||
}
|
|
||||||
|
|
||||||
if authCookie.SameSite != http.SameSiteStrictMode {
|
|
||||||
t.Fatal("cookie should be SameSite=Strict")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify JSON body does NOT contain token.
|
|
||||||
var result map[string]any
|
|
||||||
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if _, hasToken := result["token"]; hasToken {
|
|
||||||
t.Fatal("JSON body should not contain token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionStillWorks(t *testing.T) {
|
func TestSessionStillWorks(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
// Verify anonymous session creation still works.
|
// Verify anonymous session creation still works.
|
||||||
token := tserver.createSession("anon_user")
|
token := tserver.createSession("anon_user")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
t.Fatal("expected cookie for anonymous session")
|
t.Fatal("expected token for anonymous session")
|
||||||
}
|
}
|
||||||
|
|
||||||
status, state := tserver.getState(token)
|
status, state := tserver.getState(token)
|
||||||
@@ -2160,397 +2157,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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,120 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|
||||||
|
// HandleRegister creates a new user with a password.
|
||||||
|
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
request.Body = http.MaxBytesReader(
|
||||||
|
writer, request.Body, hdlr.maxBodySize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.handleRegister(writer, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) handleRegister(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
type registerRequest struct {
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload registerRequest
|
||||||
|
|
||||||
|
err := json.NewDecoder(request.Body).Decode(&payload)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid request body",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||||
|
|
||||||
|
if !validNickRe.MatchString(payload.Nick) {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid nick format",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payload.Password) < minPasswordLength {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"password must be at least 8 characters",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, clientID, token, err :=
|
||||||
|
hdlr.params.Database.RegisterUser(
|
||||||
|
request.Context(),
|
||||||
|
payload.Nick,
|
||||||
|
payload.Password,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.handleRegisterError(
|
||||||
|
writer, request, err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrSessions()
|
||||||
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
|
"id": sessionID,
|
||||||
|
"nick": payload.Nick,
|
||||||
|
"token": token,
|
||||||
|
}, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) handleRegisterError(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
if db.IsUniqueConstraintError(err) {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"nick already taken",
|
||||||
|
http.StatusConflict,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.log.Error(
|
||||||
|
"register user failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleLogin authenticates a user with nick and password.
|
// HandleLogin authenticates a user with nick and password.
|
||||||
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -86,66 +195,9 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.setAuthCookie(writer, request, token)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
"token": token,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePass handles the IRC PASS command to set a
|
|
||||||
// password on the authenticated session, enabling
|
|
||||||
// multi-client login via POST /api/v1/login.
|
|
||||||
func (hdlr *Handlers) handlePass(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 || lines[0] == "" {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdPass},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
password := lines[0]
|
|
||||||
|
|
||||||
if len(password) < minPasswordLength {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdPass},
|
|
||||||
"Password must be at least 8 characters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hdlr.params.Database.SetPassword(
|
|
||||||
request.Context(), sessionID, password,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"set password failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ type Params struct {
|
|||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
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.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *Params
|
params *Params
|
||||||
@@ -48,7 +43,6 @@ type Handlers struct {
|
|||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
channelHashcash *hashcash.ChannelValidator
|
|
||||||
stats *stats.Tracker
|
stats *stats.Tracker
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
@@ -69,7 +63,6 @@ func New(
|
|||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
channelHashcash: hashcash.NewChannelValidator(),
|
|
||||||
stats: params.Stats,
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,20 +285,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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -126,23 +126,18 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
||||||
// AllowCredentials is true so browsers include cookies in
|
|
||||||
// cross-origin API requests.
|
|
||||||
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
||||||
AllowOriginFunc: func(
|
AllowedOrigins: []string{"*"},
|
||||||
_ *http.Request, _ string,
|
|
||||||
) bool {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
AllowedMethods: []string{
|
AllowedMethods: []string{
|
||||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||||
},
|
},
|
||||||
AllowedHeaders: []string{
|
AllowedHeaders: []string{
|
||||||
"Accept", "Content-Type", "X-CSRF-Token",
|
"Accept", "Authorization",
|
||||||
|
"Content-Type", "X-CSRF-Token",
|
||||||
},
|
},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: false,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func (srv *Server) setupAPIv1(router chi.Router) {
|
|||||||
"/session",
|
"/session",
|
||||||
srv.handlers.HandleCreateSession(),
|
srv.handlers.HandleCreateSession(),
|
||||||
)
|
)
|
||||||
|
router.Post(
|
||||||
|
"/register",
|
||||||
|
srv.handlers.HandleRegister(),
|
||||||
|
)
|
||||||
router.Post(
|
router.Post(
|
||||||
"/login",
|
"/login",
|
||||||
srv.handlers.HandleLogin(),
|
srv.handlers.HandleLogin(),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const (
|
|||||||
CmdNames = "NAMES"
|
CmdNames = "NAMES"
|
||||||
CmdNick = "NICK"
|
CmdNick = "NICK"
|
||||||
CmdNotice = "NOTICE"
|
CmdNotice = "NOTICE"
|
||||||
CmdPass = "PASS"
|
|
||||||
CmdPart = "PART"
|
CmdPart = "PART"
|
||||||
CmdPing = "PING"
|
CmdPing = "PING"
|
||||||
CmdPong = "PONG"
|
CmdPong = "PONG"
|
||||||
|
|||||||
Reference in New Issue
Block a user