1 Commits
Author SHA1 Message Date
clawbot 22c65f9100 Rewrite README.md sentence by sentence (closes #190)
check / check (push) Canceled after 0s
README.md is rewritten from scratch against the prose rules on issue 190
and checked statement by statement against the code on next. It leads
with what the program is and how to use it, documents the HTTP API, the
IRC listener, the web client and neoirc-cli, puts every setting in a
reference section, and ends with development, testing and a TODO
section that points at the tracker. Every reply number is in backticks,
every error list is bulleted, and a claim that holds for only one of
the HTTP API and the IRC listener says which. Where the old text
disagreed with the code, the PR body lists the disagreement; the code
decides.

Model: fable-5-1
2026-10-27 15:57:55 +00:00
57 changed files with 974 additions and 3094 deletions
+38 -56
View File
@@ -1,73 +1,55 @@
# Rules for coding agents # AGENTS.md — Rules for AI Agents
This file holds the rules for coding agents working in this repository. ## Before Every Commit
`CONVENTIONS.md` defines the architecture. `REPO_POLICIES.md` defines the
repository standards. Both are copies of shared documents. Do not edit them
here.
## Before every commit 1. **Format**: `make fmt`
2. **Check**: `make check` — the tests and the linter, which run in Docker, and
the format check: all passing, zero issues
1. Run `make fmt`. Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make`
2. Run `make check`. It runs the tests and the linter in Docker, then the format targets.
check. All three must pass with no findings.
Never run `go test`, `golangci-lint` or `gofmt` directly. Use the `make` targets No commit lands on main with lint errors, test failures, or formatting issues.
instead, such as `make test` and `make lint`.
## Branches and pull requests ## Code Style
- Never commit to `main` or `next` directly. Every change is a pull request from - Follow CONVENTIONS.md exactly — it defines the architecture
a feature branch into `next`. - All exported types, functions, methods, and vars must have doc comments
- Only the owner merges `next` into `main`. - All packages must have a package doc comment
- A pull request that closes an issue has the title `Description (closes #N)`. - JSON tags use camelCase (not snake_case)
- One logical change per commit. - No magic numbers — use named constants
- Commit messages are in the imperative mood and short. - No inline error handling (`if err := ...; err != nil`) — use plain assignment
- Blank line before every `return` statement
- Blank line separating embedded fields from regular fields in structs
- Blank imports must have a justification comment
## Code style ## Git Workflow
- Follow `CONVENTIONS.md`. - **Never commit directly to main** — all changes go on feature branches
- Every package has a package doc comment. Test files need none. - Merge to main only when ready and passing all checks
- Every exported type, function and variable has a doc comment. Test files need - PR titles: `Description (closes #N)` when closing an issue
none. - One logical change per commit
- JSON tags are camelCase. The HTTP API's `last_id`, `pow_token` and - Commit messages: imperative mood, concise
`hashcash_bits` stay as they are, each with a `//nolint:tagliatelle` comment.
- Prefer named constants to magic numbers. The `mnd` linter checks for them
outside test files.
- No inline error handling such as `if err := f(); err != nil`. Assign the error
on its own line, then test it.
- A `return` that follows another statement in its block has a blank line before
it.
- A blank line between the embedded fields of a struct and its other fields.
- Every blank import has a comment saying why it is there. Test files need none.
## Architecture ## Architecture
- The server uses `go.uber.org/fx` for dependency injection. - Dependency injection via Uber fx — all components are fx providers
`cmd/neoircd/main.go` lists its `fx` providers, such as `db.New` and - `internal/` for all application packages — one package per concern
`handlers.New`. - Handlers return `http.HandlerFunc` using the closure pattern
- Application packages live under `internal/`, one package per concern. - Request/response structs defined inside handler closures
- `pkg/irc` holds the IRC command names and numeric reply codes. - Models embed `models.Base` for database access to relation methods
- The `web` package embeds the built web client. - Database migrations in `internal/db/schema/` — embedded via `go:embed`
- Each exported `Handle…` method of `Handlers` returns an `http.HandlerFunc`. - SQLite by default (pure Go driver: modernc.org/sqlite)
- A request struct is defined inside the method that decodes it.
- The server uses the database only through the methods of `db.Database`.
- The database migrations live in `internal/db/schema/` and are embedded with
`go:embed`.
- The database is SQLite, through the pure Go driver `modernc.org/sqlite`.
## Testing ## Testing
- Prefer table-driven tests. - Table-driven tests preferred
- Use `testing.T` helpers, not assertion libraries. - Use `testing.T` helpers, not assertion libraries
- A test file lives next to the code it tests and ends in `_test.go`. - Test files live next to the code they test
- The linter requires each test file to be in the package's external test - `_test.go` suffix, same package for white-box testing
package, such as `handlers_test`. It allows `export_test.go` and files ending
in `_internal_test.go` in the package itself.
- An `export_test.go` file gives the external tests access to the package's
unexported code.
## Dependencies ## Dependencies
- The required libraries are listed in `CONVENTIONS.md` §1. - Required libraries are listed in CONVENTIONS.md §1
- No new dependency without a reason given in the pull request. - No new dependencies without justification
- Prefer dependencies written in pure Go, which build without cgo. - Pure Go preferred (no CGO)
+4 -8
View File
@@ -33,18 +33,14 @@ RUN golangci-lint run --config .golangci.yml ./...
# golang:1.24.13-bookworm, 2026-10-06 # golang:1.24.13-bookworm, 2026-10-06
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
# -p 4 because test runs on a shared build host cap their parallelism. # -p 4 because test runs on a shared build host cap their parallelism.
# The modules download in this RUN rather than a layer of their own, because RUN go test -p 4 -timeout 90s -race -cover ./... || \
# exporting a layer that holds them is slow. Nothing reads them or the build
# cache afterwards, so go clean removes both.
RUN set -e; \
go mod download; \
go test -p 4 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -p 4 -timeout 90s -race -v ./...; exit 1; }; \ go test -p 4 -timeout 90s -race -v ./...; exit 1; }
go clean -cache -modcache
# Build stage. Nothing is wanted from either phase above; the copies # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so this stage cannot run
+167 -247
View File
@@ -25,7 +25,7 @@ neoirc is IRC with a different transport. The commands are IRC's: `PRIVMSG`,
replies, such as `353` and `433`. Channels start with `#`. The first user to replies, such as `353` and `433`. Channels start with `#`. The first user to
join a channel creates it, and a channel disappears when its last member leaves. join a channel creates it, and a channel disappears when its last member leaves.
The main differences from IRC: Four things differ from IRC:
- A client talks to the server over HTTP. It sends commands with one `POST` - A client talks to the server over HTTP. It sends commands with one `POST`
request and reads its messages with one `GET` request. request and reads its messages with one `GET` request.
@@ -48,8 +48,9 @@ The repository ships three programs:
IRC ties a session to one TCP connection. When a phone drops the connection, the IRC ties a session to one TCP connection. When a phone drops the connection, the
user loses the nick, the channels and every message sent in between. neoirc user loses the nick, the channels and every message sent in between. neoirc
keeps the session on the server. A client can disconnect and come back to its keeps the session on the server, so a client can disconnect and come back
nick, its channels and the messages it missed. without losing anything. Everything else stays IRC: the same commands, the same
replies, the same channel modes.
## Getting started ## Getting started
@@ -122,14 +123,12 @@ seen as `after`, so it reads each entry once.
Three kinds of message reach a queue: Three kinds of message reach a queue:
- **Chat messages**: `PRIVMSG` and `NOTICE`. The server echoes a session's own - Chat messages: `PRIVMSG` and `NOTICE`. The server echoes a session's own to
chat messages to every client of the session, except the IRC connection that every client of the session, except an IRC connection that sent one.
sent one.
- **Events**: `JOIN`, `PART`, `NICK`, `QUIT`, `TOPIC`, `MODE`, `KICK`, `INVITE` - **Events**: `JOIN`, `PART`, `NICK`, `QUIT`, `TOPIC`, `MODE`, `KICK`, `INVITE`
and `WALLOPS`, telling the client what other users did. When a client's own and `WALLOPS`, telling the client what other users did. When a client's own
`JOIN`, `PART`, `NICK`, `TOPIC`, `MODE` or `KICK` causes an event, every HTTP `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE` or `KICK` causes an event, every
client of its session receives it too. The IRC connection that sent the client of its session receives it too.
command gets it as an IRC line instead.
- **Numeric replies**: the server's answers to commands, such as `RPL_TOPIC` or - **Numeric replies**: the server's answers to commands, such as `RPL_TOPIC` or
`ERR_NOSUCHNICK`. A numeric reply goes only to the client that sent the `ERR_NOSUCHNICK`. A numeric reply goes only to the client that sent the
command. command.
@@ -186,8 +185,8 @@ of the response does not repeat it. A browser sends the cookie by itself. Any
other client must save it and send it back, as `curl -c` and `curl -b` do. other client must save it and send it back, as `curl -c` and `curl -b` do.
The cookie is `Secure`. Chrome, Firefox, `curl` and `neoirc-cli` send it to a The cookie is `Secure`. Chrome, Firefox, `curl` and `neoirc-cli` send it to a
server on `localhost` over plain HTTP too; Python's `requests` does not. To a server on `localhost` over plain HTTP too; Python's `requests` does not. For
remote host they send it only over HTTPS, which a reverse proxy provides. every other host the server needs HTTPS, which a reverse proxy provides.
`QUIT`, `POST /api/v1/logout`, `KILL` and the idle cleanup invalidate the `QUIT`, `POST /api/v1/logout`, `KILL` and the idle cleanup invalidate the
cookie. After that, every request with it gets `401`. cookie. After that, every request with it gets `401`.
@@ -202,7 +201,7 @@ No auth. Request body:
{ {
"nick": "alice", "nick": "alice",
"username": "alice", "username": "alice",
"pow_token": "1:20:261027:neoirc::5971e" "pow_token": "1:20:260310:neoirc::3a2f1"
} }
``` ```
@@ -222,9 +221,8 @@ Response `201`, with the cookie:
`id` is the session's number. The new client's queue already holds the welcome `id` is the session's number. The new client's queue already holds the welcome
replies `001` to `005` and the `LUSERS` replies (see replies `001` to `005` and the `LUSERS` replies (see
[Numeric replies](#numeric-replies)). It also holds the message of the day, [Numeric replies](#numeric-replies)). It also holds the message of the day when
unless that is empty (see the `MOTD` setting under `MOTD` is set.
[Configuration](#configuration)).
Errors: Errors:
@@ -235,11 +233,9 @@ Errors:
requires one. requires one.
- `402` `invalid hashcash stamp: <reason>`. - `402` `invalid hashcash stamp: <reason>`.
- `409` `nick already taken`. - `409` `nick already taken`.
- `500` `internal error`.
The server checks the stamp before the nick, and spends it only when the session The server checks the stamp before the nick, and spends it only when the session
is created. A request refused with `400`, `409` or `500` leaves the stamp is created. A request refused with `400` or `409` leaves the stamp unspent.
unspent.
#### `POST /api/v1/login`: add a client to a session #### `POST /api/v1/login`: add a client to a session
@@ -257,7 +253,7 @@ Response `200`, with a new cookie for the new client:
``` ```
The new client's queue holds the welcome replies and the `LUSERS` replies, then The new client's queue holds the welcome replies and the `LUSERS` replies, then
the message of the day unless that is empty. For each channel of the session it the message of the day when `MOTD` is set. For each channel of the session it
then holds: then holds:
- a `JOIN` event from the session's own nick with an empty `body`; - a `JOIN` event from the session's own nick with an empty `body`;
@@ -284,16 +280,16 @@ Needs auth. Response `200`:
"id": 1, "id": 1,
"nick": "alice", "nick": "alice",
"channels": [ "channels": [
{ "id": 2, "name": "#dev", "topic": "" }, { "id": 1, "name": "#general", "topic": "Welcome!" },
{ "id": 1, "name": "#general", "topic": "Welcome!" } { "id": 2, "name": "#dev", "topic": "" }
] ]
} }
``` ```
`channels` lists the channels the session has joined, sorted by name. With `channels` lists the channels the session has joined, sorted by name. With
`?initChannelState=1`, the server also queues, for each channel, the `JOIN` `?initChannelState=1`, the server also fills the calling client's queue as
event, the topic replies and the member list, as `POST /api/v1/login` does. The `POST /api/v1/login` does. The web client uses this when it loads, to restore
web client uses this when it loads, to restore its channel tabs. its channel tabs.
`GET /api/v1/users/me` is the same endpoint under another path. `GET /api/v1/users/me` is the same endpoint under another path.
@@ -302,11 +298,9 @@ web client uses this when it loads, to restore its channel tabs.
Needs auth. Query parameters: Needs auth. Query parameters:
- `after` (default `0`): return only queue entries with a number greater than - `after` (default `0`): return only queue entries with a number greater than
this. Pass the `last_id` of the previous response. A value that is not a whole this. Pass the `last_id` of the previous response.
number counts as `0`.
- `timeout` (default `0`): how many seconds to wait for a message when the queue - `timeout` (default `0`): how many seconds to wait for a message when the queue
has none. `0` answers at once. The server caps it at `30`. A negative value, has none. `0` answers at once. The server caps it at `30`.
or one that is not a whole number, counts as `0`.
Response `200`: Response `200`:
@@ -333,9 +327,9 @@ the last entry returned, or the `after` value when there was none.
When the queue has nothing after `after` and `timeout` is above `0`, the server When the queue has nothing after `after` and `timeout` is above `0`, the server
holds the request. This wait is a long poll. It ends when a message arrives for holds the request. This wait is a long poll. It ends when a message arrives for
any client of the session, or when the time is up. The answer then holds the any client of the session, or when the time is up. The answer then holds the
messages queued for this client. When there are none, `messages` is an empty messages queued for this client, or an empty `messages` array when there are
array. That happens when the message was for another client of the session, or none, as when the message was for another client of the session. A client that
when the time ran out. A client that closes the connection first gets no answer. closes the connection first gets no answer.
A client loop is: call with `after=0`, then call again with `after` set to each A client loop is: call with `after=0`, then call again with `after` set to each
response's `last_id`. Passing an earlier `after` reads the entries again. Queue response's `last_id`. Passing an earlier `after` reads the entries again. Queue
@@ -356,9 +350,7 @@ gets `ERR_UNKNOWNCOMMAND` (`421`) in the queue and `{"status": "error"}`.
Errors at the HTTP level: Errors at the HTTP level:
- `400` `invalid request body`: not JSON, or too large. A `PRIVMSG` or `NOTICE` - `400` `invalid request body`: not JSON, or too large.
also gets it when `body` is not an array of strings or `meta` is not an
object.
- `400` `command required`. - `400` `command required`.
- `401` `not registered`. - `401` `not registered`.
- `500` `internal error`. - `500` `internal error`.
@@ -373,18 +365,14 @@ Needs auth. Query parameters:
- `limit` (default `50`): at most this many messages, from 1 to 500. Any other - `limit` (default `50`): at most this many messages, from 1 to 500. Any other
value means 50. value means 50.
Response `200`: an array of `PRIVMSG` messages, oldest first. History holds only Response `200`: an array of `PRIVMSG` messages, oldest first. Only `PRIVMSG` is
`PRIVMSG`; events and `NOTICE` are left out. kept in history; events and `NOTICE` are not.
Without `before`, the answer holds the newest `limit` messages. With `before`,
it holds the newest `limit` messages older than that one.
For a channel, the caller must be a member. For a nick, only the caller's own For a channel, the caller must be a member. For a nick, only the caller's own
nick is allowed. The result is then the direct messages sent to the caller's nick is allowed. The result is then the direct messages sent to the caller's
session, under any nick it has held. It does not hold the direct messages the session, under any nick it has held. A session that takes a released nick sees
caller sent. A session that takes a released nick sees none of the messages sent none of the messages sent to the nick's earlier holder. A channel created again
to the nick's earlier holder. A channel created again after it was deleted after it was deleted starts with no history.
starts with no history.
To page back, pass the `id` of the oldest message of a page as `before`. An To page back, pass the `id` of the oldest message of a page as `before`. An
empty array means there is nothing older. empty array means there is nothing older.
@@ -411,8 +399,8 @@ Needs auth. Response `200`: every channel on the server, sorted by name, secret
```json ```json
[ [
{ "id": 2, "name": "#dev", "topic": "" }, { "id": 1, "name": "#general", "topic": "Welcome!" },
{ "id": 1, "name": "#general", "topic": "Welcome!" } { "id": 2, "name": "#dev", "topic": "" }
] ]
``` ```
@@ -434,9 +422,8 @@ Needs auth. `{name}` is the channel name without the `#`. Response `200`:
] ]
``` ```
The members are sorted by nick. `id` is the member's session number. `lastSeen` `id` is the member's session number. `lastSeen` is the time of the session's
is the time of the session's last request. An unknown channel gets `404` last request. An unknown channel gets `404` `channel not found`.
`channel not found`.
#### `POST /api/v1/logout`: remove this client #### `POST /api/v1/logout`: remove this client
@@ -535,18 +522,16 @@ with these fields:
- `params`: an array of strings with further parameters. `KICK` events and many - `params`: an array of strings with further parameters. `KICK` events and many
numeric replies have it. The server ignores one sent by a client. numeric replies have it. The server ignores one sent by a client.
- `body`: an array of strings. For chat it is the lines of text, one string per - `body`: an array of strings. For chat it is the lines of text, one string per
line. A `body` is never a bare string. On a command other than `PRIVMSG` and line. A `body` is never a bare string. Every numeric reply has a `body`; a
`NOTICE`, a `body` that is not an array of strings counts as missing. Every few, such as `RPL_CHANNELMODEIS`, carry one empty string.
numeric reply has a `body`; a few, such as `RPL_CHANNELMODEIS`, carry one
empty string.
- `ts`: when the server stored the message: UTC, RFC 3339 with fractional - `ts`: when the server stored the message: UTC, RFC 3339 with fractional
seconds, such as `2026-02-10T20:00:00.123456789Z`; trailing zeros are dropped. seconds, such as `2026-02-10T20:00:00.123456789Z`; trailing zeros are dropped.
The server assigns it. The server assigns it.
- `meta`: an object. A client may put anything in it. On a `PRIVMSG` or `NOTICE` - `meta`: an object. A client may put anything in it. On a `PRIVMSG` or `NOTICE`
the server stores and relays it unchanged. When the client sent none, the the server stores and relays it unchanged, and `{}` when the client sent none.
message carries `{}`. On every other command the server drops it, and the On every other command the server drops it, and the event carries `{}`. The
event carries `{}`. The server reads one key: `meta.hashcash` on a `PRIVMSG` server reads one key: `meta.hashcash` on a `PRIVMSG` to a channel with mode
to a channel with mode `+H`. `+H`.
### Commands ### Commands
@@ -570,8 +555,7 @@ otherwise, an error numeric comes with the HTTP response `{"status": "error"}`.
{"command": "PRIVMSG", "to": "bob", "body": ["hey"]} {"command": "PRIVMSG", "to": "bob", "body": ["hey"]}
``` ```
- `to` (required): a channel name, or a nick for a direct message. The server - `to` (required): a channel name, or a nick for a direct message.
does not add a missing `#` here: a `to` without one is a nick.
- `body` (required): one or more lines. - `body` (required): one or more lines.
- `meta` (optional): relayed unchanged. A `PRIVMSG` to a `+H` channel carries - `meta` (optional): relayed unchanged. A `PRIVMSG` to a `+H` channel carries
its stamp in `meta.hashcash`. its stamp in `meta.hashcash`.
@@ -600,30 +584,25 @@ Replies and errors:
- `ERR_NORECIPIENT` (`411`): no `to`. - `ERR_NORECIPIENT` (`411`): no `to`.
- `ERR_NOTEXTTOSEND` (`412`): no `body`, or an empty one. - `ERR_NOTEXTTOSEND` (`412`): no `body`, or an empty one.
- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. - `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist.
- `ERR_CANNOTSENDTOCHAN` (`404`): the message is not allowed: - `ERR_CANNOTSENDTOCHAN` (`404`): the message is not allowed. The `body` says
why:
- the sender is not a member; - the sender is not a member;
- the sender is banned (`+b`); - the sender is banned (`+b`);
- the channel is moderated (`+m`) and the sender has neither `+o` nor `+v`; - the channel is moderated (`+m`) and the sender has neither `+o` nor `+v`;
- the channel is `+H` and the message has no valid hashcash stamp. - the channel is `+H` and the message has no valid hashcash stamp.
The `body` names the reason in the last three cases.
- `ERR_NOSUCHNICK` (`401`): no session has the target nick. - `ERR_NOSUCHNICK` (`401`): no session has the target nick.
- `RPL_AWAY` (`301`): the target of a direct message is away. The message is - `RPL_AWAY` (`301`): the target of a direct message is away. The message is
still delivered. `params` holds the nick and `body` the away message. still delivered. `params` holds the nick and `body` the away message.
HTTP response: `{"id": "<message id>", "status": "sent"}`. HTTP response: `{"id": "<message id>", "status": "sent"}`.
A `body` or `meta` of the wrong type gets `400` `invalid request body`, and
nothing is relayed.
#### `NOTICE`: send a notice #### `NOTICE`: send a notice
The same as `PRIVMSG`, with three differences: The same as `PRIVMSG`, with three differences:
- The server sends no `RPL_AWAY` for a notice. - The server sends no `RPL_AWAY` for a notice.
- A notice to a `+H` channel needs no hashcash stamp. - A notice to a `+H` channel needs no hashcash stamp.
- A notice does not appear in history (see - A notice is not kept in history (see
[`GET /api/v1/history`](#get-apiv1history-past-messages)). [`GET /api/v1/history`](#get-apiv1history-past-messages)).
A client that receives a `NOTICE` must not answer it automatically. That is the A client that receives a `NOTICE` must not answer it automatically. That is the
@@ -847,8 +826,8 @@ changes:
positive whole number. positive whole number.
- `+b <mask>` / `-b <mask>`: add or remove a ban. `+b` with no mask lists the - `+b <mask>` / `-b <mask>`: add or remove a ban. `+b` with no mask lists the
bans; anyone may do that. bans; anyone may do that.
- `+H <bits>` / `-H`: require a hashcash stamp on every `PRIVMSG`, or stop - `+H <bits>` / `-H`: require a hashcash stamp of `bits` leading zero bits on
requiring one. `bits`, from 1 to 40, is the number of leading zero bits. every `PRIVMSG`, from 1 to 40, or stop requiring one.
Every member, the operator included, receives a `MODE` event. For `o`, `v`, `i`, Every member, the operator included, receives a `MODE` event. For `o`, `v`, `i`,
`m`, `s` and `t` its `body` is one string: `["+o bob"]`, `["+m"]`. For `k`, `l` `m`, `s` and `t` its `body` is one string: `["+o bob"]`, `["+m"]`. For `k`, `l`
@@ -871,8 +850,8 @@ Errors of a channel `MODE`:
- `ERR_NEEDMOREPARAMS` (`461`): no `to`, or `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, - `ERR_NEEDMOREPARAMS` (`461`): no `to`, or `+o`, `-o`, `+v`, `-v`, `+k`, `+l`,
`-b` or `+H` without its value. `-b` or `+H` without its value.
- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. - `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist.
- `ERR_CHANOPRIVSNEEDED` (`482`): a change from the list above by a user who is - `ERR_CHANOPRIVSNEEDED` (`482`): a change by a user who is not a channel
not a channel operator. operator.
- `ERR_UNKNOWNMODE` (`472`), in three cases: - `ERR_UNKNOWNMODE` (`472`), in three cases:
- a change the list above does not have; - a change the list above does not have;
- a `+H` value that is not a whole number from 1 to 40; - a `+H` value that is not a whole number from 1 to 40;
@@ -885,9 +864,9 @@ gets `ERR_USERSDONTMATCH` (`502`). With no `body`, the server sends
`RPL_UMODEIS` (`221`) with the modes in `body`: `+`, `+o`, `+w` or `+ow`. With a `RPL_UMODEIS` (`221`) with the modes in `body`: `+`, `+o`, `+w` or `+ow`. With a
`body`, `body[0]` is a mode string starting with `+` or `-`, such as `+w` or `body`, `body[0]` is a mode string starting with `+` or `-`, such as `+w` or
`-o`. `w` can be set and unset. `o` can only be unset; `OPER` sets it. A string `-o`. `w` can be set and unset. `o` can only be unset; `OPER` sets it. A string
that sets `o`, has any other letter or has no letter gets `ERR_UMODEUNKNOWNFLAG` with any other letter, or without a letter, gets `ERR_UMODEUNKNOWNFLAG` (`501`)
(`501`) and changes nothing. An accepted change is answered with `RPL_UMODEIS` and changes nothing. An accepted change is answered with `RPL_UMODEIS` (`221`)
(`221`) and the new modes, and nobody else is told. and the new modes, and nobody else is told.
```json ```json
{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } { "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] }
@@ -970,9 +949,9 @@ HTTP response: `{"status": "ok"}`.
``` ```
A `body` sets the away message; several strings are joined with spaces. No A `body` sets the away message; several strings are joined with spaces. No
`body`, or one empty string, clears it. The reply is `RPL_NOWAWAY` (`306`) or `body` clears it. The reply is `RPL_NOWAWAY` (`306`) or `RPL_UNAWAY` (`305`). A
`RPL_UNAWAY` (`305`). A user who sends a `PRIVMSG` to an away user receives user who sends a `PRIVMSG` to an away user receives `RPL_AWAY` (`301`).
`RPL_AWAY` (`301`). `USERHOST` marks an away user with `-`. `USERHOST` marks an away user with `-`.
HTTP response: `{"status": "ok"}`. HTTP response: `{"status": "ok"}`.
@@ -984,9 +963,9 @@ HTTP response: `{"status": "ok"}`.
The server adds a missing `#`. The reply is `RPL_NAMREPLY` (`353`), when the The server adds a missing `#`. The reply is `RPL_NAMREPLY` (`353`), when the
channel has members, then `RPL_ENDOFNAMES` (`366`). `RPL_NAMREPLY` has `params` channel has members, then `RPL_ENDOFNAMES` (`366`). `RPL_NAMREPLY` has `params`
`["=", channel]` and a `body` with one string. That string holds the members' `["=", channel]` and a `body` with one string: the members' hostmasks separated
hostmasks in nick order, separated by spaces. A `@` before a hostmask marks a by spaces, each with `@` before a channel operator and `+` before a voiced
channel operator, and a `+` marks a voiced member: member:
```json ```json
{ {
@@ -994,7 +973,7 @@ channel operator, and a `+` marks a voiced member:
"code": 353, "code": 353,
"to": "alice", "to": "alice",
"params": ["=", "#general"], "params": ["=", "#general"],
"body": ["alice!alice@host2 +bob!bob@host3 @op1!op1@host1"] "body": ["@op1!op1@host1 alice!alice@host2 +bob!bob@host3"]
} }
``` ```
@@ -1011,7 +990,7 @@ HTTP response: `{"status": "ok"}`.
{ "command": "LIST" } { "command": "LIST" }
``` ```
The reply is one `RPL_LIST` (`322`) per channel, in name order, with `params` The reply is one `RPL_LIST` (`322`) per channel, with `params`
`[channel, member count]` and the topic in `body`, then `RPL_LISTEND` (`323`). `[channel, member count]` and the topic in `body`, then `RPL_LISTEND` (`323`).
Secret (`+s`) channels the user is not a member of are left out. Secret (`+s`) channels the user is not a member of are left out.
@@ -1079,8 +1058,8 @@ HTTP response: `{"status": "ok"}`.
The server sends the welcome replies `001` to `005`, the `LUSERS` replies and The server sends the welcome replies `001` to `005`, the `LUSERS` replies and
the message of the day again: `RPL_MOTDSTART` (`375`), one `RPL_MOTD` (`372`) the message of the day again: `RPL_MOTDSTART` (`375`), one `RPL_MOTD` (`372`)
per line, `RPL_ENDOFMOTD` (`376`). When the message of the day is empty, the per line, `RPL_ENDOFMOTD` (`376`). With an empty `MOTD` setting, the welcome and
welcome and `LUSERS` replies come alone. `LUSERS` replies come alone.
HTTP response: `{"status": "ok"}`. HTTP response: `{"status": "ok"}`.
@@ -1189,16 +1168,13 @@ name and `to` the client's nick. `005` arrives as `RPL_BOUNCE`, its name in
RFC 2812. Every reply has a `body` with one string, the reply's text; a few RFC 2812. Every reply has a `body` with one string, the reply's text; a few
carry an empty string. carry an empty string.
The server sends these to every new client, on `POST /api/v1/session` and Sent to every new client, on `POST /api/v1/session`, `POST /api/v1/login` and
`POST /api/v1/login`, and again on the `MOTD` command. `375` to `376` are left `MOTD`; `375` to `376` only when `MOTD` is set:
out when the message of the day is empty (see the `MOTD` setting under
[Configuration](#configuration)).
- `001` `RPL_WELCOME`: `Welcome to the network, <nick>`. - `001` `RPL_WELCOME`: `Welcome to the network, <nick>`.
- `002` `RPL_YOURHOST`: `Your host is <server name>, running version <version>`. - `002` `RPL_YOURHOST`: `Your host is <server name>, running version <version>`.
`<version>` is `dev` in a binary built without one.
- `003` `RPL_CREATED`: `This server was created <date the server started>`. - `003` `RPL_CREATED`: `This server was created <date the server started>`.
- `004` `RPL_MYINFO`: `params` `[server name, version, "ow", "Hbiklmnostv"]`. - `004` `RPL_MYINFO`: `params` `[server name, version, "", "ikmnostl"]`.
- `005` `RPL_BOUNCE`: `params` - `005` `RPL_BOUNCE`: `params`
`["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"]`, `["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"]`,
`body` `["are supported by this server"]`. `body` `["are supported by this server"]`.
@@ -1213,49 +1189,32 @@ out when the message of the day is empty (see the `MOTD` setting under
- `372` `RPL_MOTD`: one per line of the message of the day, each `- <line>`. - `372` `RPL_MOTD`: one per line of the message of the day, each `- <line>`.
- `376` `RPL_ENDOFMOTD`: `End of /MOTD command.` - `376` `RPL_ENDOFMOTD`: `End of /MOTD command.`
Answers to commands. Each line names the command that gets the reply, then what Answers to commands, as described under each command:
`params` holds:
- `221` `RPL_UMODEIS`: user `MODE`. No `params`; the modes are in `body`. - `221` `RPL_UMODEIS`: user `MODE`.
- `256` `RPL_ADMINME`: `ADMIN`. `[server name]`. - `256` `RPL_ADMINME`, `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259`
- `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259` `RPL_ADMINEMAIL`: `ADMIN`. `RPL_ADMINEMAIL`: `ADMIN`.
No `params`. - `301` `RPL_AWAY`: a `PRIVMSG` to an away user.
- `301` `RPL_AWAY`: a `PRIVMSG` to an away user. `[nick]`; the away message is - `302` `RPL_USERHOST`: `USERHOST`.
in `body`. - `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`.
- `302` `RPL_USERHOST`: `USERHOST`. No `params`. - `311` `RPL_WHOISUSER`, `312` `RPL_WHOISSERVER`, `313` `RPL_WHOISOPERATOR`,
- `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`. No `params`. `317` `RPL_WHOISIDLE`, `318` `RPL_ENDOFWHOIS`, `319` `RPL_WHOISCHANNELS`,
- `311` `RPL_WHOISUSER`: `WHOIS`. `[nick, username, hostname, "*"]`. `338` `RPL_WHOISACTUALLY`: `WHOIS`.
- `312` `RPL_WHOISSERVER`: `WHOIS`. `[nick, server name]`. - `315` `RPL_ENDOFWHO`, `352` `RPL_WHOREPLY`: `WHO`.
- `313` `RPL_WHOISOPERATOR`: `WHOIS`. `[nick]`. - `322` `RPL_LIST`, `323` `RPL_LISTEND`: `LIST`.
- `317` `RPL_WHOISIDLE`: `WHOIS`. `[nick, idle seconds, signon Unix time]`. - `324` `RPL_CHANNELMODEIS`, `329` `RPL_CREATIONTIME`: a channel `MODE` query;
- `318` `RPL_ENDOFWHOIS`: `WHOIS`. `[nick]`. `324` also after `+H` and `-H`.
- `319` `RPL_WHOISCHANNELS`: `WHOIS`. `[nick]`; the channels are in `body`. - `331` `RPL_NOTOPIC`, `332` `RPL_TOPIC`, `333` `RPL_TOPICWHOTIME`: `JOIN`,
- `338` `RPL_WHOISACTUALLY`: `WHOIS`. `[nick, IP address]`. `TOPIC` and the channel state after login or `?initChannelState=1`. `333` has
- `315` `RPL_ENDOFWHO`: `WHO`. `[channel]`. `params` `[channel, nick who set the topic, Unix time]`.
- `352` `RPL_WHOREPLY`: `WHO`. - `341` `RPL_INVITING`: `INVITE`.
`[channel, username, hostname, server name, nick, "H"]`. - `351` `RPL_VERSION`: `VERSION`.
- `322` `RPL_LIST`: `LIST`. `[channel, member count]`; the topic is in `body`. - `353` `RPL_NAMREPLY`, `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel
- `323` `RPL_LISTEND`: `LIST`. No `params`. state after login or `?initChannelState=1`.
- `324` `RPL_CHANNELMODEIS`: a channel `MODE` query, and a `+H` or `-H` change. - `367` `RPL_BANLIST`, `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`.
`[channel, mode string]`. - `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`.
- `329` `RPL_CREATIONTIME`: a channel `MODE` query. `[channel, Unix time]`. - `381` `RPL_YOUREOPER`: `OPER`.
- `331` `RPL_NOTOPIC`: `JOIN` and the channel state after login or - `391` `RPL_TIME`: `TIME`.
`?initChannelState=1`. `[channel]`.
- `332` `RPL_TOPIC`: `JOIN`, `TOPIC` and the channel state. `[channel]`; the
topic is in `body`.
- `333` `RPL_TOPICWHOTIME`: `JOIN`, `TOPIC` and the channel state.
`[channel, nick who set the topic, Unix time]`.
- `341` `RPL_INVITING`: `INVITE`. `[nick, channel]`.
- `351` `RPL_VERSION`: `VERSION`. `["neoirc-<version>.", server name]`.
- `353` `RPL_NAMREPLY`: `JOIN`, `NAMES` and the channel state. `["=", channel]`;
the members are in `body`.
- `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel state. `[channel]`.
- `367` `RPL_BANLIST`: `MODE #channel +b`.
`[channel, mask, nick who set it, Unix time]`.
- `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`. `[channel]`.
- `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`. No `params`.
- `381` `RPL_YOUREOPER`: `OPER`. No `params`.
- `391` `RPL_TIME`: `TIME`. `[server name]`; the time is in `body`.
Errors, each with what its `params` holds: Errors, each with what its `params` holds:
@@ -1346,9 +1305,9 @@ of the `X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see
Every user can see a hostmask: in `RPL_WHOISUSER` (`311`), `RPL_WHOREPLY` Every user can see a hostmask: in `RPL_WHOISUSER` (`311`), `RPL_WHOREPLY`
(`352`), `RPL_USERHOST` (`302`), `RPL_NAMREPLY` (`353`) and (`352`), `RPL_USERHOST` (`302`), `RPL_NAMREPLY` (`353`) and
`GET /api/v1/channels/{name}/members`. Bans match against it. Only a server `GET /api/v1/channels/{name}/members`. Bans match against it. Only a server
operator sees IP addresses, in `RPL_WHOISACTUALLY` (`338`) over the HTTP API, operator sees IP addresses, in `RPL_WHOISACTUALLY` (`338`), and only that of the
and only that of the user's newest client. When the lookup gave no name, the user's newest client. When the lookup gave no name, the hostname everyone sees
hostname everyone sees is the IP address. is the IP address.
### Hashcash ### Hashcash
@@ -1377,8 +1336,7 @@ has six fields separated by colons:
- `counter`: a hex number the client varies until the hash fits. - `counter`: a hex number the client varies until the hash fits.
The SHA-256 hash of the whole stamp string must start with at least The SHA-256 hash of the whole stamp string must start with at least
`hashcash_bits` zero bits. `1:20:261027:neoirc::5971e` is a real 20-bit stamp `hashcash_bits` zero bits. Example: `1:20:260310:neoirc::3a2f1b`. In pseudocode:
for 27 October 2026; its hash starts with `00000e`. In pseudocode:
``` ```
counter = 0 counter = 0
@@ -1400,9 +1358,9 @@ The server accepts a stamp when all of these hold:
- the hash has the zero bits; - the hash has the zero bits;
- the stamp has not been spent. - the stamp has not been spent.
The server keeps spent stamps in memory until their date is 48 hours old; a Spent stamps are kept in memory for 48 hours; a restart forgets them. The client
restart forgets them. The client sends the stamp as `pow_token` in sends the stamp as `pow_token` in `POST /api/v1/session`. The web client and
`POST /api/v1/session`. The web client and `neoirc-cli` compute it on their own. `neoirc-cli` compute it on their own.
#### Channel stamps #### Channel stamps
@@ -1420,26 +1378,22 @@ The stamp is bound to the channel and to the message:
The version, `bits` and date rules of session stamps apply to channel stamps The version, `bits` and date rules of session stamps apply to channel stamps
too, with the channel's `+H` value as the difficulty. too, with the channel's `+H` value as the difficulty.
This message carries a real 20-bit stamp for 27 October 2026, for the `body` Example:
spelled exactly `["hello world"]`:
```json ```json
{ {
"command": "PRIVMSG", "command": "PRIVMSG",
"to": "#general", "to": "#general",
"body": ["hello world"], "body": ["hello world"],
"meta": { "meta": { "hashcash": "1:20:260317:#general:a1b2c3...:1f4a" }
"hashcash": "1:20:261027:#general:d7f54e4fb4d97b407a45ce7ba369ec4cb70d15337fb33a1adfcb4f523aeb1b32:16ec2f"
}
} }
``` ```
The server checks the stamp after membership, bans and `+m`, so a message The server checks the stamp after membership, bans and `+m`, so a message
refused for one of those does not spend it. It then records the stamp's hash in refused for one of those does not spend it. It then records the stamp's hash in
the database and refuses the stamp for at least a year. A missing, invalid or the database and refuses the stamp for a year. A missing, invalid or spent stamp
spent stamp gets `ERR_CANNOTSENDTOCHAN` (`404`), with the reason in `body`. gets `ERR_CANNOTSENDTOCHAN` (`404`), with the reason in `body`. `NOTICE` needs
`NOTICE` needs no stamp. The web client and `neoirc-cli` do not compute channel no stamp. The web client and `neoirc-cli` do not compute channel stamps.
stamps.
### Login rate limit ### Login rate limit
@@ -1450,10 +1404,9 @@ over the limit gets `429` `too many login attempts, try again later` with
attempt is more than 15 minutes old. Session creation has no rate limit; the attempt is more than 15 minutes old. Session creation has no rate limit; the
hashcash stamp is its cost. hashcash stamp is its cost.
The IP address comes from the first entry of `X-Forwarded-For`, else from The IP address comes from `X-Forwarded-For` or `X-Real-IP` when present. Without
`X-Real-IP`, else from the TCP connection. Without a reverse proxy that a reverse proxy that sets those headers, a client can set them itself and so
overwrites those headers, a client can set them itself and so pick any address pick any address (see [Reverse proxy](#reverse-proxy)).
(see [Reverse proxy](#reverse-proxy)).
### Writing a client ### Writing a client
@@ -1487,22 +1440,16 @@ while True:
resp = session.get(f"{BASE}/messages", resp = session.get(f"{BASE}/messages",
params={"after": last_id, "timeout": 15}, params={"after": last_id, "timeout": 15},
timeout=20) # longer than the long-poll timeout timeout=20) # longer than the long-poll timeout
data = resp.json()
last_id = data.get("last_id", last_id)
for msg in data.get("messages", []):
print(f"[{msg['command']}] <{msg.get('from', '')}> "
f"{' '.join(msg.get('body', []))}")
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
continue continue
except requests.exceptions.RequestException as exc: except Exception as exc:
print(f"error: {exc}") print(f"error: {exc}")
time.sleep(2) time.sleep(2)
continue
if resp.status_code == 401:
break # the session is gone
if resp.status_code != 200:
time.sleep(2)
continue
data = resp.json()
last_id = data["last_id"]
for msg in data["messages"]:
print(f"[{msg['command']}] <{msg.get('from', '')}> "
f"{' '.join(msg['body'])}")
``` ```
The same loop in a browser, where the cookie is sent automatically: The same loop in a browser, where the cookie is sent automatically:
@@ -1517,10 +1464,6 @@ async function pollLoop() {
{ credentials: "same-origin" }, { credentials: "same-origin" },
); );
if (resp.status === 401) break; // the session is gone if (resp.status === 401) break; // the session is gone
if (resp.status !== 200) {
await new Promise((r) => setTimeout(r, 2000));
continue;
}
const data = await resp.json(); const data = await resp.json();
lastId = data.last_id; lastId = data.last_id;
for (const msg of data.messages) handleMessage(msg); for (const msg of data.messages) handleMessage(msg);
@@ -1531,9 +1474,6 @@ async function pollLoop() {
} }
``` ```
Both loops keep `after` where it was unless the answer is `200`, and stop on
`401`.
Advice for client authors: Advice for client authors:
- Set the HTTP timeout above the long-poll timeout. With `timeout=15`, give the - Set the HTTP timeout above the long-poll timeout. With `timeout=15`, give the
@@ -1541,9 +1481,7 @@ Advice for client authors:
- Keep `after` moving. Reset it to `0` only to read the queue again. - Keep `after` moving. Reset it to `0` only to read the queue again.
- Expect your own messages. The server echoes a session's own `PRIVMSG` and - Expect your own messages. The server echoes a session's own `PRIVMSG` and
`NOTICE` to all its clients. It does the same with the events of its `JOIN`, `NOTICE` to all its clients. It does the same with the events of its `JOIN`,
`PART`, `NICK`, `TOPIC`, `MODE` and `KICK`. Show them. A `PRIVMSG` or `NOTICE` `PART`, `NICK`, `TOPIC`, `MODE` and `KICK`. Show them, or drop them by `id`.
can instead be dropped by the `id` its `POST` response returned; the response
to an event carries no `id`.
- Key a direct-message window by the other user's nick: `to` when `from` is you, - Key a direct-message window by the other user's nick: `to` when `from` is you,
`from` otherwise. `from` otherwise.
- On `401`, the session is gone or the cookie is wrong. Create a new session, or - On `401`, the session is gone or the cookie is wrong. Create a new session, or
@@ -1581,10 +1519,9 @@ password of the wrong length is refused with a `NOTICE` once registration
completes. completes.
After registration the client receives `001` to `005`, the `LUSERS` replies and After registration the client receives `001` to `005`, the `LUSERS` replies and
the message of the day, or `ERR_NOMOTD` (`422`) when that is empty. The `004` the message of the day, or `ERR_NOMOTD` (`422`) when `MOTD` is empty. The `004`
user modes are `ow` and its channel modes are `Himnostv`. The `005` tokens are modes are `mnst` and the `005` tokens `CHANTYPES=#`, `NICKLEN=32`,
`CHANTYPES=#`, `NICKLEN=32`, `PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `NETWORK=<server name>`.
`NETWORK=<server name>`.
`CAP LS` is answered with an empty capability list; other `CAP` subcommands are `CAP LS` is answered with an empty capability list; other `CAP` subcommands are
ignored. ignored.
@@ -1593,10 +1530,10 @@ When a connection has sent nothing for 90 seconds, the server sends `PING`. When
nothing arrives in the 30 seconds after that, the server closes the connection. nothing arrives in the 30 seconds after that, the server closes the connection.
Any line, such as the client's `PONG`, starts both waits again. Any line, such as the client's `PONG`, starts both waits again.
Closing the connection ends the session, as `QUIT` does over the HTTP API. After Closing the connection, with or without `QUIT`, ends the session, as `QUIT` does
a `QUIT`, channel peers see the reason the client gave, or `Client quit` when it over the HTTP API. Its channel peers see a `QUIT` with the reason
gave none. Without a `QUIT`, they see `Connection closed`. Every command after `Connection closed`; the reason given to `QUIT` is not relayed. Every command
registration counts as activity for the idle cleanup. after registration counts as activity for the idle cleanup.
### Commands ### Commands
@@ -1635,11 +1572,6 @@ They behave as over the HTTP API, with the IRC syntax and these differences:
end reply. end reply.
- `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (`431`). - `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (`431`).
- `KICK` without a reason uses the kicked user's nick as the reason. - `KICK` without a reason uses the kicked user's nick as the reason.
- `MOTD` sends only `375` to `376`, or `ERR_NOMOTD` (`422`) when the message of
the day is empty, without the welcome and `LUSERS` replies.
- `PASS` after registration answers with a `NOTICE` when it stores the password.
A password of the wrong length also gets a `NOTICE`, not `ERR_NEEDMOREPARAMS`
(`461`).
### What IRC clients see ### What IRC clients see
@@ -1648,9 +1580,8 @@ Each line of a multi-line `body` reaches an IRC client as its own `PRIVMSG` or
appear with the prefix `nick!nick@*`. A mode change made over the HTTP API appear with the prefix `nick!nick@*`. A mode change made over the HTTP API
arrives as an IRC `MODE` line with each value as its own parameter, such as arrives as an IRC `MODE` line with each value as its own parameter, such as
`MODE #general +k secretkey`. An `INVITE` arrives as `MODE #general +k secretkey`. An `INVITE` arrives as
`:alice!alice@* INVITE bob #general`. Numeric replies, `JOIN`, `PART`, `KICK`, `:alice!alice@* INVITE bob #general`. Numeric replies, `KICK`, `TOPIC`, `NICK`,
`TOPIC`, `NICK`, `QUIT` (as `Quit: <reason>`) and `WALLOPS` arrive in their IRC `QUIT` (as `Quit: <reason>`) and `WALLOPS` arrive in their IRC forms.
forms.
The listener does not queue a client's own commands back to it. It sends the The listener does not queue a client's own commands back to it. It sends the
echo itself, as IRC does, and for `PRIVMSG` and `NOTICE` sends none. Other echo itself, as IRC does, and for `PRIVMSG` and `NOTICE` sends none. Other
@@ -1697,14 +1628,14 @@ It starts disconnected. Commands start with `/`:
- `/quit`: send `QUIT` and exit. `/help`: the command list. - `/quit`: send `QUIT` and exit. `/help`: the command list.
Text without a `/` goes as a `PRIVMSG` to the current target. `/join` and Text without a `/` goes as a `PRIVMSG` to the current target. `/join` and
`/query` set the target to the channel or user they open. `/window`, `/w` and `/query` set the target to the channel or user they open. `/window` and `/w` set
Alt with a digit set it to the channel or user of the window they switch to. A it to the channel or user of the window they switch to; a switch to window `0`
switch to window `0` clears it, and so does `/part` of the target's channel. leaves it as it was. Alt and a digit switches the window shown and leaves the
target as it was. `/part` of the target's channel clears it.
The client reads its queue with `timeout=15` and retries after two seconds on an The client reads its queue with `timeout=15` and retries after two seconds on an
error. It sends no hashcash stamps for `+H` channels. It sends the auth cookie error. It sends no hashcash stamps for `+H` channels. It sends the auth cookie
over plain HTTP to a server on `localhost` or a loopback address such as to a server on `localhost` over plain HTTP, and otherwise only over HTTPS.
`127.0.0.1`, and otherwise only over HTTPS.
## Running the server ## Running the server
@@ -1712,15 +1643,12 @@ over plain HTTP to a server on `localhost` or a loopback address such as
Every setting is an environment variable. The server also reads a `.env` file in Every setting is an environment variable. The server also reads a `.env` file in
its working directory, and a YAML file `neoirc.yaml` in `/etc/neoirc/` or its working directory, and a YAML file `neoirc.yaml` in `/etc/neoirc/` or
`$HOME/.config/neoirc/` with the same names as keys. A variable from the `$HOME/.config/neoirc/` with the same names as keys. An environment variable
environment wins over one in `.env`, and both win over `neoirc.yaml`. An wins over the file. An environment variable set to the empty string counts as
environment variable set to the empty string counts as unset, except unset, except `IRC_LISTEN_ADDR`, where the empty string turns the IRC listener
`IRC_LISTEN_ADDR`, where the empty string turns the IRC listener off. A off. A `neoirc.yaml` that is not valid YAML stops the server at startup.
`neoirc.yaml` that is not valid YAML stops the server at startup. A setting that
does not parse or is out of range also stops the server at startup, and the
error names the setting.
- `PORT` (default `8080`): the HTTP port. `0` lets the system pick a free port. - `PORT` (default `8080`): the HTTP port.
- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The - `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The
empty string turns the listener off. empty string turns the listener off.
- `DBURL` (default - `DBURL` (default
@@ -1734,30 +1662,27 @@ error names the setting.
hashcash resource use `neoirc`, and `GET /api/v1/server` returns an empty hashcash resource use `neoirc`, and `GET /api/v1/server` returns an empty
string. string.
- `MOTD` (default: a built-in banner): the message of the day. Lines are - `MOTD` (default: a built-in banner): the message of the day. Lines are
separated by newlines. An empty environment variable counts as unset and gives separated by newlines.
the banner. To send no message of the day, put `MOTD: ""` in `neoirc.yaml`.
- `NEOIRC_HASHCASH_BITS` (default `20`): the difficulty of the stamp - `NEOIRC_HASHCASH_BITS` (default `20`): the difficulty of the stamp
`POST /api/v1/session` needs. `0` turns the requirement off. `POST /api/v1/session` needs. `0` turns the requirement off.
- `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` (default empty): the name and - `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` (default empty): the name and
password `OPER` accepts. `OPER` fails while either is empty. password `OPER` accepts. `OPER` fails while either is empty.
- `MAX_MESSAGE_SIZE` (default `4096`): the largest request body, in bytes, for - `MAX_MESSAGE_SIZE` (default `4096`): the largest request body, in bytes, for
`POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`. It `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`.
must be at least `1`. - `LOGIN_RATE_LIMIT` (default `1`) and `LOGIN_RATE_BURST` (default `5`): login
- `LOGIN_RATE_LIMIT` (default `1`): login attempts allowed per second per IP attempts allowed per second per IP address, and the burst above that rate. A
address. A fraction such as `0.5` is allowed. It must be above `0`. value of `0` or less means the default.
- `LOGIN_RATE_BURST` (default `5`): how many attempts an address may make at - `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a Go duration such as `720h`
once before the rate applies. It must be at least `1`. or `24h`. A client with no request for this long is removed, and a session
- `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a duration with a unit, `h` left with no client ends. See [Data lifecycle](#data-lifecycle). A value that
for hours, `m` for minutes or `s` for seconds, such as `720h` or `36h30m`. A is not a duration is logged, and the default applies.
client with no request for this long is removed, and a session left with no - `MESSAGE_MAX_AGE` (default `720h`): messages older than this are deleted. A
client ends. See [Data lifecycle](#data-lifecycle). It must be above zero. value that is not a duration is logged, and no message is deleted.
- `MESSAGE_MAX_AGE` (default `720h`): messages older than this duration are - `QUEUE_MAX_AGE` (default `720h`): queue entries older than this are deleted. A
deleted. It must be above zero. value that is not a duration is logged, and no entry is deleted.
- `QUEUE_MAX_AGE` (default `720h`): queue entries older than this duration are
deleted. It must be above zero.
- `DEBUG` (default `false`): `true` turns on debug logging. - `DEBUG` (default `false`): `true` turns on debug logging.
- `SENTRY_DSN` (default empty): when set, panics in HTTP requests are reported - `SENTRY_DSN` (default empty): when set, panics in HTTP requests are reported
to Sentry. A value that is not a valid DSN stops the server at startup. to Sentry.
- `METRICS_USERNAME` and `METRICS_PASSWORD` (default empty): the HTTP basic auth - `METRICS_USERNAME` and `METRICS_PASSWORD` (default empty): the HTTP basic auth
credentials of `GET /metrics`. The endpoint exists only when credentials of `GET /metrics`. The endpoint exists only when
`METRICS_USERNAME` is set. `METRICS_USERNAME` is set.
@@ -1777,8 +1702,7 @@ NEOIRC_HASHCASH_BITS=20
### Docker ### Docker
`make docker` builds the image, tagged `neoirc`. The build runs the tests and `make docker` builds the image, tagged `neoirc`. The image:
the linter first and stops when either fails. The image:
- is Alpine with the `neoircd` binary and CA certificates; - is Alpine with the `neoircd` binary and CA certificates;
- runs as the user `neoirc`; - runs as the user `neoirc`;
@@ -1804,11 +1728,10 @@ Run the server behind a reverse proxy that terminates TLS. The server itself
speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only
over HTTPS. The proxy must: over HTTPS. The proxy must:
- Set `X-Forwarded-For` to the client's address, replacing any value the client - Set `X-Forwarded-For` or `X-Real-IP` to the client's address, replacing any
sent. The server reads that header first and trusts it from anyone; it reads value the client sent. The server trusts these headers from anyone. Without a
`X-Real-IP` only when `X-Forwarded-For` is absent. Without a proxy that proxy that overwrites them, a client can choose its own address, which sets
overwrites the header, a client can choose its own address, which sets its its hostname and lets it dodge the login rate limit.
hostname and lets it dodge the login rate limit.
- Wait at least 60 seconds for a response. A long poll can take 30 seconds, and - Wait at least 60 seconds for a response. A long poll can take 30 seconds, and
the server's own write timeout is 60 seconds. the server's own write timeout is 60 seconds.
@@ -1845,8 +1768,8 @@ The IRC listener has no TLS.
### Limits and headers ### Limits and headers
- The server reads a request within 10 seconds and writes a response within 60 - The server reads a request within 10 seconds and writes a response within 60
seconds. A request still running after 60 seconds is cancelled, and the seconds. A request is cancelled after 60 seconds and gets `504` with no body.
connection closes without a response. Headers may be up to 1 MiB. Headers may be up to 1 MiB.
- A long poll waits at most 30 seconds. - A long poll waits at most 30 seconds.
- Every response to a well-formed request, except to a cross-origin preflight, - Every response to a well-formed request, except to a cross-origin preflight,
carries a `Content-Security-Policy` header: carries a `Content-Security-Policy` header:
@@ -1861,16 +1784,16 @@ The IRC listener has no TLS.
The server logs with Go's `slog`, one line per event, to standard output. The The server logs with Go's `slog`, one line per event, to standard output. The
lines are JSON, or plain text when standard output is a terminal. Every HTTP lines are JSON, or plain text when standard output is a terminal. Every HTTP
request is logged with its method, URL, protocol, status, latency, remote request is logged with its method, URL, status, latency, remote address, user
address, user agent, referrer and request ID. `DEBUG=true` adds debug-level agent and request ID. `DEBUG=true` adds debug-level lines.
lines.
### The database ### The database
The server stores everything in one SQLite database, through the pure Go driver The server stores everything in one SQLite database, through the pure Go driver
`modernc.org/sqlite`. It uses one database connection, so reads and writes take `modernc.org/sqlite`. It uses one database connection, so reads and writes take
turns. On its first start it runs `internal/db/schema/001_initial.sql`, which turns. On startup it creates the tables from
creates the tables, and records that in `schema_migrations`. `internal/db/schema/001_initial.sql` if they do not exist, and records the
schema version in `schema_migrations`.
The tables: The tables:
@@ -1970,16 +1893,15 @@ standard. Each script has a `make` target of the same name, except
`script/install-precommit`, which is `make hooks`, and `script/cibuild`, `script/install-precommit`, which is `make hooks`, and `script/cibuild`,
`script/precommit` and `script/projectname`, which have none. `script/precommit` and `script/projectname`, which have none.
- `script/bootstrap`: installs `make`, `git`, Go, Node and `yarn`, and - `script/bootstrap`: installs what development needs: `make`, `git`, Node and
`prettier` through `yarn`. Docker must already be installed. `yarn` (for prettier) and Go.
- `script/setup`: makes a fresh clone ready: runs `script/bootstrap`, then - `script/setup`: makes a fresh clone ready: runs `script/bootstrap`, then
`script/install-precommit`. `script/install-precommit`.
- `script/test`: builds the `Dockerfile`'s `test` stage, which runs the tests - `script/test`: builds the `Dockerfile`'s `test` stage, which runs the tests
with `-race`. with `-race`.
- `script/lint`: builds the `Dockerfile`'s `lint` stage, which runs - `script/lint`: builds the `Dockerfile`'s `lint` stage, which runs
`golangci-lint`. golangci-lint.
- `script/fmt`: formats the Go code with `gofmt` and the Markdown with - `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier.
`prettier`.
- `script/fmt-check`: the same, read-only; fails if anything would change. - `script/fmt-check`: the same, read-only; fails if anything would change.
- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. - `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`.
- `script/docker`: builds the image, tagged `neoirc`. - `script/docker`: builds the image, tagged `neoirc`.
@@ -1997,9 +1919,7 @@ standard. Each script has a `make` target of the same name, except
`/`. To include the web client, build it first: in `web/`, run `/`. To include the web client, build it first: in `web/`, run
`yarn install --frozen-lockfile` and `sh build.sh`. `yarn install --frozen-lockfile` and `sh build.sh`.
- `make run`: builds and starts the server. `make debug` does the same with - `make run`: builds and starts the server. `make debug` does the same with
`DEBUG=1`. With the default `DBURL`, both stop at startup when `DEBUG=1`.
`/var/lib/neoirc/` does not exist or is not writable. Set `DBURL` to a
writable path to avoid that.
- `make clean`: removes `bin/`. - `make clean`: removes `bin/`.
- `make check`: runs `script/check`. `make test`, `make lint`, `make fmt` and - `make check`: runs `script/check`. `make test`, `make lint`, `make fmt` and
`make fmt-check` run the script of the same name. `make fmt-check` run the script of the same name.
@@ -2011,8 +1931,8 @@ standard. Each script has a `make` target of the same name, except
The tests and the linter run inside Docker, as stages of the `Dockerfile`, so The tests and the linter run inside Docker, as stages of the `Dockerfile`, so
every developer and the CI run the same toolchain. `make test` builds the `test` every developer and the CI run the same toolchain. `make test` builds the `test`
stage: `go test -race` over every package, with a 90-second limit. `make lint` stage: `go test -race` over every package, with a 90-second limit. `make lint`
builds the `lint` stage, which runs `golangci-lint` with the shared builds the `lint` stage, which runs golangci-lint with the shared
`.golangci.yml`. `make fmt-check` runs `gofmt` and `prettier` on the host. Tests `.golangci.yml`. `make fmt-check` runs `gofmt` and prettier on the host. Tests
live next to the code, most in `_test` packages. A test that needs a database live next to the code, most in `_test` packages. A test that needs a database
gets an in-memory one of its own from `internal/dbtest`, which holds a second gets an in-memory one of its own from `internal/dbtest`, which holds a second
connection open to keep it. connection open to keep it.
@@ -2020,9 +1940,9 @@ connection open to keep it.
The `Dockerfile` has five stages: The `Dockerfile` has five stages:
1. `web-builder`: installs the web client's Node dependencies and bundles 1. `web-builder`: installs the web client's Node dependencies and bundles
`web/src/app.jsx` with `esbuild` into `web/dist/`. `web/src/app.jsx` with esbuild into `web/dist/`.
2. `lint`: runs `golangci-lint`, with placeholder files in `web/dist/` so that 2. `lint`: runs golangci-lint, with placeholder files in `web/dist/` so that it
it does not wait for the web build. does not wait for the web build.
3. `test`: runs the tests, with the same placeholders. 3. `test`: runs the tests, with the same placeholders.
4. `builder`: runs only after `lint` and `test` have passed. It compiles 4. `builder`: runs only after `lint` and `test` have passed. It compiles
`neoircd` with the real web client, and compiles `neoirc-cli` to check that `neoircd` with the real web client, and compiles `neoirc-cli` to check that
+79 -141
View File
@@ -34,30 +34,13 @@ type App struct {
// Run creates and runs the CLI application. // Run creates and runs the CLI application.
func Run() { func Run() {
err := run(NewUI())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// run starts the client on ui and returns when ui stops. Tests
// call it with ui on a simulation screen.
func run(ui *UI) error {
app := &App{ //nolint:exhaustruct app := &App{ //nolint:exhaustruct
ui: ui, ui: NewUI(),
nick: "guest", nick: "guest",
} }
app.ui.OnInput(app.handleInput) app.ui.OnInput(app.handleInput)
app.ui.SetStatus(app.nick, "", "disconnected")
// SetStatus and AddStatus wait for the event loop, which
// app.ui.Run starts below, so they run on a goroutine. The
// nick is read first, as a typed /nick may change app.nick.
nick := app.nick
go func() {
app.ui.SetStatus(nick, "", "disconnected")
app.ui.AddStatus( app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client", "Welcome to neoirc-cli — an IRC-style client",
@@ -67,9 +50,12 @@ func run(ui *UI) error {
"[white] to begin, " + "[white] to begin, " +
"or [yellow]/help[white] for commands", "or [yellow]/help[white] for commands",
) )
}()
return app.ui.Run() err := app.ui.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} }
func (a *App) handleInput(text string) { func (a *App) handleInput(text string) {
@@ -108,7 +94,7 @@ func (a *App) handleInput(text string) {
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(
"[red]Send error: " + escape(err.Error()), "[red]Send error: " + err.Error(),
) )
} }
} }
@@ -166,7 +152,7 @@ func (a *App) dispatchInfoCommand(cmd, args string) {
a.cmdWhois(args) a.cmdWhois(args)
default: default:
a.ui.AddStatus( a.ui.AddStatus(
"[red]Unknown command: " + escape(cmd), "[red]Unknown command: " + cmd,
) )
} }
} }
@@ -182,7 +168,7 @@ func (a *App) cmdConnect(serverURL string) {
serverURL = strings.TrimRight(serverURL, "/") serverURL = strings.TrimRight(serverURL, "/")
a.ui.AddStatus("Connecting to " + escape(serverURL) + "...") a.ui.AddStatus("Connecting to " + serverURL + "...")
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
@@ -192,37 +178,29 @@ func (a *App) cmdConnect(serverURL string) {
resp, err := client.CreateSession(nick) resp, err := client.CreateSession(nick)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Connection failed: " + escape(err.Error()), "[red]Connection failed: %v", err,
) ))
return return
} }
// The poll loop of an earlier connection is stopped in the same
// step that replaces the client, so it never polls with the new
// one.
a.mu.Lock() a.mu.Lock()
a.client = client a.client = client
a.nick = resp.Nick a.nick = resp.Nick
a.connected = true a.connected = true
a.lastQID = 0 a.lastQID = 0
if a.stopPoll != nil {
close(a.stopPoll)
}
stop := make(chan struct{})
a.stopPoll = stop
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[green]Connected! Nick: %s, Session: %d", "[green]Connected! Nick: %s, Session: %d",
escape(resp.Nick), resp.ID, resp.Nick, resp.ID,
)) ))
a.ui.SetStatus(resp.Nick, "", "connected") a.ui.SetStatus(resp.Nick, "", "connected")
go a.pollLoop(stop) a.stopPoll = make(chan struct{})
go a.pollLoop()
} }
func (a *App) cmdNick(nick string) { func (a *App) cmdNick(nick string) {
@@ -244,7 +222,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus( a.ui.AddStatus(
"Nick set to " + escape(nick) + "Nick set to " + nick +
" (will be used on connect)", " (will be used on connect)",
) )
@@ -256,9 +234,9 @@ func (a *App) cmdNick(nick string) {
Body: []string{nick}, Body: []string{nick},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Nick change failed: " + escape(err.Error()), "[red]Nick change failed: %v", err,
) ))
return return
} }
@@ -269,7 +247,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus(nick, target, "connected") a.ui.SetStatus(nick, target, "connected")
a.ui.AddStatus("Nick changed to " + escape(nick)) a.ui.AddStatus("Nick changed to " + nick)
} }
func (a *App) cmdJoin(channel string) { func (a *App) cmdJoin(channel string) {
@@ -297,9 +275,9 @@ func (a *App) cmdJoin(channel string) {
err := a.client.JoinChannel(channel) err := a.client.JoinChannel(channel)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Join failed: " + escape(err.Error()), "[red]Join failed: %v", err,
) ))
return return
} }
@@ -337,9 +315,9 @@ func (a *App) cmdPart(channel string) {
err := a.client.PartChannel(channel) err := a.client.PartChannel(channel)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Part failed: " + escape(err.Error()), "[red]Part failed: %v", err,
) ))
return return
} }
@@ -384,9 +362,9 @@ func (a *App) cmdMsg(args string) {
Body: []string{text}, Body: []string{text},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: " + escape(err.Error()), "[red]Send failed: %v", err,
) ))
} }
} }
@@ -402,16 +380,10 @@ func (a *App) cmdQuery(nick string) {
a.mu.Lock() a.mu.Lock()
a.target = nick a.target = nick
myNick := a.nick myNick := a.nick
connected := a.connected
a.mu.Unlock() a.mu.Unlock()
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
a.ui.SwitchToBuffer(nick) a.ui.SwitchToBuffer(nick)
a.ui.SetStatus(myNick, nick, connStatus) a.ui.SetStatus(myNick, nick, "connected")
} }
func (a *App) cmdTopic(args string) { func (a *App) cmdTopic(args string) {
@@ -445,9 +417,9 @@ func (a *App) cmdTopic(args string) {
Body: []string{args}, Body: []string{args},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Topic set failed: " + escape(err.Error()), "[red]Topic set failed: %v", err,
) ))
} }
} }
@@ -471,9 +443,9 @@ func (a *App) cmdNames() {
members, err := a.client.GetMembers(target) members, err := a.client.GetMembers(target)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Names failed: " + escape(err.Error()), "[red]Names failed: %v", err,
) ))
return return
} }
@@ -486,7 +458,7 @@ func (a *App) cmdNames() {
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s", "[cyan]*** Members of %s: %s",
escape(target), escape(strings.Join(nicks, " ")), target, strings.Join(nicks, " "),
)) ))
} }
@@ -517,9 +489,9 @@ func (a *App) cmdList() {
channels, err := a.client.ListChannels() channels, err := a.client.ListChannels()
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]List failed: " + escape(err.Error()), "[red]List failed: %v", err,
) ))
return return
} }
@@ -528,7 +500,7 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s %s", escape(ch.Name), escape(ch.Topic), " %s %s", ch.Name, ch.Topic,
)) ))
} }
@@ -550,9 +522,9 @@ func (a *App) cmdMotd() {
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
) )
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]MOTD failed: " + escape(err.Error()), "[red]MOTD failed: %v", err,
) ))
} }
} }
@@ -588,9 +560,9 @@ func (a *App) cmdWho(args string) {
}, },
) )
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]WHO failed: " + escape(err.Error()), "[red]WHO failed: %v", err,
) ))
} }
} }
@@ -619,9 +591,9 @@ func (a *App) cmdWhois(args string) {
}, },
) )
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]WHOIS failed: " + escape(err.Error()), "[red]WHOIS failed: %v", err,
) ))
} }
} }
@@ -638,37 +610,24 @@ func (a *App) cmdWindow(args string) {
_, _ = fmt.Sscanf(args, "%d", &bufIndex) _, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.mu.Lock()
nick := a.nick
connected := a.connected
a.mu.Unlock()
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
// Read the name before switching. A message from the server can
// add a buffer at any time, but only at the end, so bufIndex
// still names this buffer when SwitchBuffer runs.
name, found := a.ui.BufferName(bufIndex)
if found {
a.ui.SwitchBuffer(bufIndex) a.ui.SwitchBuffer(bufIndex)
if name != "(status)" {
a.mu.Lock() a.mu.Lock()
a.target = name nick := a.nick
a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
buf := a.ui.buffers[bufIndex]
if buf.Name != "(status)" {
a.mu.Lock()
a.target = buf.Name
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus( a.ui.SetStatus(
nick, name, connStatus, nick, buf.Name, "connected",
) )
} else { } else {
a.mu.Lock() a.ui.SetStatus(nick, "", "connected")
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", connStatus)
} }
} }
} }
@@ -716,23 +675,16 @@ func (a *App) cmdHelp() {
} }
} }
// pollLoop long-polls for messages in the background until stop is // pollLoop long-polls for messages in the background.
// closed. A later /connect closes stop under a.mu as it replaces the func (a *App) pollLoop() {
// client and the cursor, so the loop checks stop under a.mu too. A
// stopped loop then neither polls with the new client nor moves the
// new cursor.
func (a *App) pollLoop(stop <-chan struct{}) {
for { for {
a.mu.Lock()
select { select {
case <-stop: case <-a.stopPoll:
a.mu.Unlock()
return return
default: default:
} }
a.mu.Lock()
client := a.client client := a.client
lastQID := a.lastQID lastQID := a.lastQID
a.mu.Unlock() a.mu.Unlock()
@@ -750,23 +702,11 @@ func (a *App) pollLoop(stop <-chan struct{}) {
continue continue
} }
// A poll sent before stop was closed answers for the earlier
// connection.
a.mu.Lock()
select {
case <-stop:
a.mu.Unlock()
return
default:
}
if result.LastID > 0 { if result.LastID > 0 {
a.mu.Lock()
a.lastQID = result.LastID a.lastQID = result.LastID
}
a.mu.Unlock() a.mu.Unlock()
}
for i := range result.Messages { for i := range result.Messages {
a.handleServerMessage(&result.Messages[i]) a.handleServerMessage(&result.Messages[i])
@@ -824,7 +764,7 @@ func (a *App) handlePrivmsgEvent(
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s", "[gray]%s [green]<%s>[white] %s",
timestamp, escape(msg.From), escape(text), timestamp, msg.From, text,
)) ))
} }
@@ -837,7 +777,7 @@ func (a *App) handleJoinEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s", "[gray]%s [yellow]*** %s has joined %s",
timestamp, escape(msg.From), escape(msg.To), timestamp, msg.From, msg.To,
)) ))
} }
@@ -856,11 +796,11 @@ func (a *App) handlePartEvent(
line := fmt.Sprintf( line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s", "[gray]%s [yellow]*** %s has left %s",
timestamp, escape(msg.From), escape(msg.To), timestamp, msg.From, msg.To,
) )
if reason != "" { if reason != "" {
line += " (" + escape(reason) + ")" line += " (" + reason + ")"
} }
if msg.From == myNick { if msg.From == myNick {
@@ -881,12 +821,12 @@ func (a *App) handleQuitEvent(
if reason != "" { if reason != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)", "[gray]%s [yellow]*** %s has quit (%s)",
timestamp, escape(msg.From), escape(reason), timestamp, msg.From, reason,
)) ))
} else { } else {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit", "[gray]%s [yellow]*** %s has quit",
timestamp, escape(msg.From), timestamp, msg.From,
)) ))
} }
} }
@@ -913,7 +853,7 @@ func (a *App) handleNickEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s", "[gray]%s [yellow]*** %s is now known as %s",
timestamp, escape(msg.From), escape(newNick), timestamp, msg.From, newNick,
)) ))
} }
@@ -925,7 +865,7 @@ func (a *App) handleNoticeEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s", "[gray]%s [magenta]--%s-- %s",
timestamp, escape(msg.From), escape(text), timestamp, msg.From, text,
)) ))
} }
@@ -941,7 +881,7 @@ func (a *App) handleTopicEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s", "[gray]%s [cyan]*** %s set topic: %s",
timestamp, escape(msg.From), escape(text), timestamp, msg.From, text,
)) ))
} }
@@ -951,12 +891,10 @@ func (a *App) handleDefaultEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
// The brackets around the command are escaped with it, as tview
// reads a command such as "[MODE]" as a tag.
if text != "" { if text != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white]%s %s", "[gray]%s [white][%s] %s",
timestamp, escape("["+msg.Command+"]"), escape(text), timestamp, msg.Command, text,
)) ))
} }
} }
File diff suppressed because it is too large Load Diff
+14 -83
View File
@@ -16,10 +16,7 @@ type Buffer struct {
Unread int Unread int
} }
// UI manages the terminal interface. Most of its methods wait for // UI manages the terminal interface.
// the tview event loop to run their update. Code that runs on that
// loop, such as a key handler, must not call them: it would wait
// for itself.
type UI struct { type UI struct {
app *tview.Application app *tview.Application
messages *tview.TextView messages *tview.TextView
@@ -30,17 +27,7 @@ type UI struct {
buffers []*Buffer buffers []*Buffer
currentBuffer int currentBuffer int
// The nick, target and connection state last passed to
// SetStatus.
nick string
target string
connStatus string
onInput func(string) onInput func(string)
// inputDone is closed once the line last passed to onInput
// has been handled. Only callOnInput, on the event loop, uses
// it.
inputDone chan struct{}
} }
// NewUI creates the tview-based IRC-like UI. // NewUI creates the tview-based IRC-like UI.
@@ -185,32 +172,13 @@ func (ui *UI) SetStatus(
nick, target, connStatus string, nick, target, connStatus string,
) { ) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.nick = nick ui.renderStatusBar(nick, target, connStatus)
ui.target = target
ui.connStatus = connStatus
ui.refreshStatusBar()
}) })
} }
// BufferName returns the name of the buffer at index bufIndex, // BufferCount returns the number of buffers.
// and whether there is such a buffer. func (ui *UI) BufferCount() int {
func (ui *UI) BufferName(bufIndex int) (string, bool) { return len(ui.buffers)
var (
name string
found bool
)
ui.app.QueueUpdate(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
name = ui.buffers[bufIndex].Name
found = true
})
return name, found
} }
// BufferIndex returns the index of a named buffer. // BufferIndex returns the index of a named buffer.
@@ -235,21 +203,9 @@ func (ui *UI) initMessages() {
ui.messages.SetBorder(false) ui.messages.SetBorder(false)
} }
// escape returns text so that the message window shows it exactly
// as written. It writes each "[" as "[[:]": a "[" followed by
// another "[" never starts a tag, and the tag "[:]" changes
// nothing. tview.Escape is not enough: it does not escape a bracket
// pair holding a symbol such as "|" or "^", and nothing it adds
// stops a tag with a URL, such as "[:::y|z]".
func escape(text string) string {
return strings.ReplaceAll(text, "[", "[[:]")
}
func (ui *UI) initStatusBar() { func (ui *UI) initStatusBar() {
// Colour tags stay off. With them on, tview reads text in ui.statusBar = tview.NewTextView().
// square brackets, such as "[connected]" or part of a nick, SetDynamicColors(true)
// as a tag and drops it.
ui.statusBar = tview.NewTextView()
ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite) ui.statusBar.SetTextColor(tcell.ColorWhite)
} }
@@ -270,7 +226,10 @@ func (ui *UI) initInput() {
} }
ui.input.SetText("") ui.input.SetText("")
ui.callOnInput(text)
if ui.onInput != nil {
ui.onInput(text)
}
}) })
} }
@@ -281,11 +240,10 @@ func (ui *UI) initKeyCapture() {
return event return event
} }
// Alt and a digit runs /window with that digit.
// SwitchBuffer alone would leave the target as it was.
r := event.Rune() r := event.Rune()
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
ui.callOnInput("/window " + string(r)) idx := int(r - '0')
ui.SwitchBuffer(idx)
return nil return nil
} }
@@ -295,35 +253,8 @@ func (ui *UI) initKeyCapture() {
) )
} }
// callOnInput passes text to the input callback on a new
// goroutine. The key handlers call it on the event loop, and the
// callback waits for that loop to update the screen, so they must
// not wait for the callback. The goroutine first waits for the
// line before it to be handled, which keeps lines in the order
// they were typed.
func (ui *UI) callOnInput(text string) {
if ui.onInput == nil {
return
}
previous := ui.inputDone
done := make(chan struct{})
ui.inputDone = done
go func() {
if previous != nil {
<-previous
}
ui.onInput(text)
close(done)
}()
}
// refreshStatusBar redraws the status bar with the nick, target
// and connection state last passed to SetStatus.
func (ui *UI) refreshStatusBar() { func (ui *UI) refreshStatusBar() {
ui.renderStatusBar(ui.nick, ui.target, ui.connStatus) // Placeholder; full refresh needs nick/target context.
} }
func (ui *UI) renderStatusBar( func (ui *UI) renderStatusBar(
+7 -103
View File
@@ -3,12 +3,8 @@ package config
import ( import (
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math"
"os" "os"
"strconv"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -32,8 +28,6 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" + const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -69,8 +63,6 @@ type Config struct {
} }
// New creates a new Config by reading from files and environment variables. // New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does
// not parse or is out of range.
func New( func New(
_ fx.Lifecycle, params Params, _ fx.Lifecycle, params Params,
) (*Config, error) { ) (*Config, error) {
@@ -115,27 +107,29 @@ func New(
cfg := &Config{ cfg := &Config{
DBURL: viper.GetString("DBURL"), DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"), OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"), IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
log: log, log: log,
params: &params, params: &params,
} }
err = parseSettings(cfg)
if err != nil {
return nil, err
}
// Viper counts an empty environment variable as unset, but an // Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off. // empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR") ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
@@ -150,93 +144,3 @@ func New(
return cfg, nil return cfg, nil
} }
// parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the
// system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
return errors.Join(
parseInt("PORT", 0, &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits),
parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst),
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
)
}
// parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
raw := viper.GetString(key)
parsed, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if parsed < minimum {
return fmt.Errorf("%w: %s is %s, but must be at least %d",
errOutOfRange, key, raw, minimum)
}
*value = parsed
return nil
}
// parseFloat sets *value to the setting key read as a floating-point
// number. It returns an error when the value does not parse or is not a
// finite number above zero.
func parseFloat(key string, value *float64) error {
raw := viper.GetString(key)
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return fmt.Errorf(
"%w: %s is %s, but must be a finite number above zero",
errOutOfRange, key, raw)
}
*value = parsed
return nil
}
// parseBool sets *value to the setting key read as true or false.
func parseBool(key string, value *bool) error {
parsed, err := strconv.ParseBool(viper.GetString(key))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
*value = parsed
return nil
}
// checkDuration returns an error naming the setting key when value is
// not a Go duration above zero.
func checkDuration(key, value string) error {
duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, value)
}
return nil
}
+2 -149
View File
@@ -2,7 +2,6 @@ package config_test
import ( import (
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@@ -11,8 +10,7 @@ import (
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
) )
// loadConfig runs config.New and returns its result. func newConfig(t *testing.T) *config.Config {
func loadConfig(t *testing.T) (*config.Config, error) {
t.Helper() t.Helper()
globs := &globals.Globals{ globs := &globals.Globals{
@@ -26,15 +24,9 @@ func loadConfig(t *testing.T) (*config.Config, error) {
t.Fatal(err) t.Fatal(err)
} }
return config.New( cfg, err := config.New(
nil, config.Params{Globals: globs, Logger: log}, nil, config.Params{Globals: globs, Logger: log},
) )
}
func newConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := loadConfig(t)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -42,145 +34,6 @@ func newConfig(t *testing.T) *config.Config {
return cfg return cfg
} }
func TestUnsetSettingsKeepDefaults(t *testing.T) {
// Viper counts an empty environment variable as unset.
t.Setenv("PORT", "")
t.Setenv("MAX_MESSAGE_SIZE", "")
t.Setenv("NEOIRC_HASHCASH_BITS", "")
t.Setenv("LOGIN_RATE_BURST", "")
t.Setenv("LOGIN_RATE_LIMIT", "")
t.Setenv("DEBUG", "")
t.Setenv("MAINTENANCE_MODE", "")
t.Setenv("MESSAGE_MAX_AGE", "")
t.Setenv("QUEUE_MAX_AGE", "")
t.Setenv("SESSION_IDLE_TIMEOUT", "")
// Each duration setting defaults to 30 days.
const thirtyDays = "720h"
want := settings{
Port: 8080,
MaxMessageSize: 4096,
HashcashBits: 20,
LoginRateBurst: 5,
LoginRateLimit: 1,
Debug: false,
MaintenanceMode: false,
MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays,
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
func TestValidSettingsAreUsed(t *testing.T) {
// Each whole number is the lowest value its setting allows, and 1ns
// is the shortest duration allowed.
t.Setenv("PORT", "0")
t.Setenv("MAX_MESSAGE_SIZE", "1")
t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "1")
t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "1ns")
t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "45m")
want := settings{
Port: 0,
MaxMessageSize: 1,
HashcashBits: 0,
LoginRateBurst: 1,
LoginRateLimit: 0.5,
Debug: true,
MaintenanceMode: true,
MessageMaxAge: "1ns",
QueueMaxAge: "1h30m",
SessionIdleTimeout: "45m",
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
// settings holds the Config fields whose settings config.New parses.
type settings struct {
Port int
MaxMessageSize int
HashcashBits int
LoginRateBurst int
LoginRateLimit float64
Debug bool
MaintenanceMode bool
MessageMaxAge string
QueueMaxAge string
SessionIdleTimeout string
}
func settingsOf(cfg *config.Config) settings {
return settings{
Port: cfg.Port,
MaxMessageSize: cfg.MaxMessageSize,
HashcashBits: cfg.HashcashBits,
LoginRateBurst: cfg.LoginRateBurst,
LoginRateLimit: cfg.LoginRateLimit,
Debug: cfg.Debug,
MaintenanceMode: cfg.MaintenanceMode,
MessageMaxAge: cfg.MessageMaxAge,
QueueMaxAge: cfg.QueueMaxAge,
SessionIdleTimeout: cfg.SessionIdleTimeout,
}
}
func TestBadSettingStopsStartup(t *testing.T) {
// For each setting, the first value does not parse and the others
// parse but are out of range.
tests := []struct {
key string
values []string
}{
{key: "PORT", values: []string{"80a", "-1"}},
{key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}},
{key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}},
{key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}},
{key: "LOGIN_RATE_LIMIT", values: []string{
"fast", "0", "-1", "NaN", "Inf", "-Inf",
}},
{key: "DEBUG", values: []string{"yes"}},
{key: "MAINTENANCE_MODE", values: []string{"on"}},
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
}
for _, test := range tests {
for _, value := range test.values {
t.Run(test.key+"="+value, func(t *testing.T) {
t.Setenv(test.key, value)
_, err := loadConfig(t)
if err == nil {
t.Fatalf("%s=%q: got no error", test.key, value)
}
msg := err.Error()
if !strings.Contains(msg, test.key) ||
!strings.Contains(msg, value) {
t.Errorf("error %q does not name %s and %q",
msg, test.key, value)
}
})
}
}
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) { func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test. // t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "") t.Setenv("IRC_LISTEN_ADDR", "")
+8 -1
View File
@@ -10,6 +10,13 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
var bcryptCost = bcrypt.DefaultCost
// SetBcryptCost overrides the bcrypt cost.
// Use bcrypt.MinCost in tests to avoid slow hashing.
func SetBcryptCost(cost int) { bcryptCost = cost }
var errNoPassword = errors.New( var errNoPassword = errors.New(
"account has no password set", "account has no password set",
) )
@@ -22,7 +29,7 @@ func (database *Database) SetPassword(
password string, password string,
) error { ) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.DefaultCost, []byte(password), bcryptCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return fmt.Errorf("hash password: %w", err)
+5
View File
@@ -85,6 +85,11 @@ func New(
return database, nil return database, nil
} }
// GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB {
return database.conn
}
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
-76
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "testing"
"time"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
) )
@@ -68,78 +67,3 @@ func (database *Database) ExecForTest(
return nil return nil
} }
// GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB {
return database.conn
}
// GetOrCreateChannel returns channel id, creating if needed.
// Uses INSERT OR IGNORE to avoid TOCTOU races.
func (database *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
var channelID int64
err = database.conn.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&channelID)
if err != nil {
return 0, fmt.Errorf("get channel: %w", err)
}
return channelID, nil
}
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
return nil
}
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
+14
View File
@@ -0,0 +1,14 @@
package db_test
import (
"os"
"testing"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/db"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
+110
View File
@@ -320,6 +320,29 @@ type ClientHostInfo struct {
Hostname string Hostname string
} }
// GetClientHostInfo returns the IP and hostname for a
// client.
func (database *Database) GetClientHostInfo(
ctx context.Context,
clientID int64,
) (*ClientHostInfo, error) {
var info ClientHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT ip, hostname
FROM clients WHERE id = ?`,
clientID,
).Scan(&info.IP, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get client host info: %w", err,
)
}
return &info, nil
}
// SetSessionOper sets the is_oper flag on a session. // SetSessionOper sets the is_oper flag on a session.
func (database *Database) SetSessionOper( func (database *Database) SetSessionOper(
ctx context.Context, ctx context.Context,
@@ -410,6 +433,54 @@ func (database *Database) GetChannelByName(
return channelID, nil return channelID, nil
} }
// GetOrCreateChannel returns channel id, creating if needed.
// Uses INSERT OR IGNORE to avoid TOCTOU races.
func (database *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
var channelID int64
err = database.conn.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&channelID)
if err != nil {
return 0, fmt.Errorf("get channel: %w", err)
}
return channelID, nil
}
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
return nil
}
// Errors that CreateOrJoinChannel refuses a join with. // Errors that CreateOrJoinChannel refuses a join with.
var ( var (
ErrBannedFromChannel = errors.New("banned from channel") ErrBannedFromChannel = errors.New("banned from channel")
@@ -1056,6 +1127,23 @@ func (database *Database) GetChannelMemberIDs(
return scanInt64s(rows) return scanInt64s(rows)
} }
// GetSessionChannelIDs returns channel IDs for a session.
func (database *Database) GetSessionChannelIDs(
ctx context.Context,
sessionID int64,
) ([]int64, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get session channel ids: %w", err,
)
}
return scanInt64s(rows)
}
// InsertMessage stores a message and returns its DB ID. // InsertMessage stores a message and returns its DB ID.
// toSessionID is the session a DM or INVITE is sent to and // toSessionID is the session a DM or INVITE is sent to and
// toChannelID the channel a channel message is sent to; // toChannelID the channel a channel message is sent to;
@@ -2372,6 +2460,28 @@ func (database *Database) AddChannelInvite(
return nil return nil
} }
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
// --- Tier 2: Secret (+s) --- // --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode. // IsChannelSecret checks if a channel has +s mode.
+16 -3
View File
@@ -125,7 +125,7 @@ func TestCreateSessionStoresIP(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := database.CreateSession( sessionID, clientID, _, err := database.CreateSession(
ctx, "ipuser", "ident", "host.example.com", ctx, "ipuser", "ident", "host.example.com",
"192.168.1.42", "192.168.1.42",
) )
@@ -147,8 +147,8 @@ func TestCreateSessionStoresIP(t *testing.T) {
) )
} }
clientInfo, err := database.GetLatestClientForSession( clientInfo, err := database.GetClientHostInfo(
ctx, sessionID, ctx, clientID,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -169,6 +169,19 @@ func TestCreateSessionStoresIP(t *testing.T) {
} }
} }
func TestGetClientHostInfoNotFound(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
_, err := database.GetClientHostInfo(
t.Context(), 99999,
)
if err == nil {
t.Fatal("expected error for nonexistent client")
}
}
func TestGetSessionHostInfoNotFound(t *testing.T) { func TestGetSessionHostInfoNotFound(t *testing.T) {
t.Parallel() t.Parallel()
+25
View File
@@ -0,0 +1,25 @@
package db
import (
"context"
"database/sql"
"log/slog"
)
// NewTestDatabaseFromConn creates a Database wrapping an
// existing *sql.DB connection. Intended for integration
// tests in other packages.
func NewTestDatabaseFromConn(conn *sql.DB) *Database {
return &Database{ //nolint:exhaustruct
conn: conn,
log: slog.Default(),
}
}
// RunMigrations applies all schema migrations. Exposed
// for integration tests in other packages.
func (database *Database) RunMigrations(
ctx context.Context,
) error {
return database.runMigrations(ctx)
}
+2 -22
View File
@@ -1,5 +1,5 @@
// Package dbtest gives each test a database of its own and runs raw // Package dbtest gives each test a database of its own. Only tests
// SQL on it. Only tests may import it. // may import it.
package dbtest package dbtest
import ( import (
@@ -37,23 +37,3 @@ func NewURL(tb testing.TB) string {
return dbURL return dbURL
} }
// Exec runs one raw SQL statement on the database at dbURL, over a
// connection of its own. A test uses it for what the methods of
// db.Database cannot do, such as creating a trigger that makes a
// write fail.
func Exec(tb testing.TB, dbURL, statement string) {
tb.Helper()
pool, err := sql.Open("sqlite", dbURL)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
defer func() { _ = pool.Close() }()
_, err = pool.ExecContext(tb.Context(), statement)
if err != nil {
tb.Fatalf("run %q: %v", statement, err)
}
}
+7 -54
View File
@@ -188,7 +188,7 @@ func (hdlr *Handlers) handleCreateSession(
var payload createRequest var payload createRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO // 004 RPL_MYINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick, ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, service.UserModes, "Hbiklmnostv"}, []string{srvName, version, "", "ikmnostl"},
"", "",
) )
@@ -925,7 +925,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
var payload commandRequest var payload commandRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -994,7 +994,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, command, target, body, meta, bodyLines,
) )
case irc.CmdJoin: case irc.CmdJoin:
hdlr.handleJoin( hdlr.handleJoin(
@@ -1139,18 +1139,8 @@ func (hdlr *Handlers) handlePrivmsg(
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
bodyLines func() []string,
) { ) {
lines, ok := checkBodyAndMeta(body, meta)
if !ok {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
if target == "" { if target == "" {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1165,6 +1155,7 @@ func (hdlr *Handlers) handlePrivmsg(
return return
} }
lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1198,44 +1189,6 @@ func (hdlr *Handlers) handlePrivmsg(
) )
} }
// checkBodyAndMeta returns the lines in body. It returns false
// when body is present and is not an array of strings, or meta
// is present and is not an object. Each line is decoded as any
// because decoding into []string turns a null line into "". A
// null body or meta decodes without an error into nil.
func checkBodyAndMeta(body, meta json.RawMessage) ([]string, bool) {
var lines []string
if body != nil {
var values []any
err := json.Unmarshal(body, &values)
if err != nil || values == nil {
return nil, false
}
for _, value := range values {
line, isString := value.(string)
if !isString {
return nil, false
}
lines = append(lines, line)
}
}
if meta != nil {
var fields map[string]any
err := json.Unmarshal(meta, &fields)
if err != nil || fields == nil {
return nil, false
}
}
return lines, true
}
// respondIRCError enqueues a numeric error reply, notifies // respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}. // the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError( func (hdlr *Handlers) respondIRCError(
@@ -3238,7 +3191,7 @@ func (hdlr *Handlers) handleWho(
// 315 RPL_ENDOFWHO (empty result) // 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick, ctx, clientID, irc.RplEndOfWho, nick,
[]string{channel}, []string{target},
"End of /WHO list", "End of /WHO list",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
+47 -332
View File
@@ -8,12 +8,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os" "os"
"reflect"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -47,25 +45,22 @@ import (
const templateDBURL = "file:template?mode=memory&cache=shared" const templateDBURL = "file:template?mode=memory&cache=shared"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
template := fx.New( db.SetBcryptCost(bcrypt.MinCost)
fx.NopLogger,
fx.Supply(
&globals.Globals{},
&config.Config{DBURL: templateDBURL},
),
fx.Provide(logger.New, newTestDB),
// fx makes the database only if something asks for it.
fx.Invoke(func(*db.Database) {}),
)
err := template.Start(context.Background()) template, err := sql.Open("sqlite", templateDBURL)
if err != nil {
panic(err)
}
err = db.NewTestDatabaseFromConn(template).
RunMigrations(context.Background())
if err != nil { if err != nil {
panic(err) panic(err)
} }
code := m.Run() code := m.Run()
_ = template.Stop(context.Background()) _ = template.Close()
os.Exit(code) os.Exit(code)
} }
@@ -140,7 +135,6 @@ type testServer struct {
t *testing.T t *testing.T
fxApp *fxtest.App fxApp *fxtest.App
broker *broker.Broker broker *broker.Broker
dbURL string
database *db.Database database *db.Database
handlers *handlers.Handlers handlers *handlers.Handlers
} }
@@ -235,7 +229,6 @@ func newTestServerWith(
t: t, t: t,
fxApp: app, fxApp: app,
broker: brk, broker: brk,
dbURL: dbURL,
database: database, database: database,
handlers: hdlr, handlers: hdlr,
} }
@@ -802,104 +795,6 @@ func TestCreateSessionMalformed(t *testing.T) {
} }
} }
func (tserver *testServer) postBody(
path, token, body string,
) (int, map[string]any) {
tserver.t.Helper()
resp, err := doRequestAuth(
tserver.t, http.MethodPost, tserver.url(path), token,
strings.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("post %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
return resp.StatusCode, result
}
// TestRequestBodyLimit sends each endpoint a small valid JSON value
// followed by other bytes. Only whitespace may follow the value, and
// the whole body counts towards MAX_MESSAGE_SIZE.
func TestRequestBodyLimit(t *testing.T) {
t.Parallel()
const (
maxMessageSize = 4096 // the default MAX_MESSAGE_SIZE
nick = "alice_bodylimit"
)
endpoints := []struct {
name, path, value string
acceptedStatus int
}{
{
"create session", apiSession,
`{"nick":"bob_bodylimit"}`,
http.StatusCreated,
},
{
"login", "/api/v1/login",
`{"nick":"` + nick + `","password":"` + testPassword + `"}`,
http.StatusOK,
},
{
"post message", apiMessages,
`{"command":"PING"}`,
http.StatusOK,
},
}
for _, endpoint := range endpoints {
t.Run(endpoint.name, func(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession(nick)
tserver.setPassword(nick)
bytesToLimit := maxMessageSize - len(endpoint.value)
for _, trailing := range []string{
strings.Repeat(" ", bytesToLimit+1),
strings.Repeat("x", bytesToLimit+1),
"x",
"{}",
} {
status, result := tserver.postBody(
endpoint.path, token, endpoint.value+trailing,
)
if status != http.StatusBadRequest ||
result["error"] != "invalid request body" {
t.Errorf(
"%d bytes of %.10q after the value: expected "+
"400 invalid request body, got %d: %v",
len(trailing), trailing, status, result,
)
}
}
status, result := tserver.postBody(
endpoint.path, token,
endpoint.value+strings.Repeat(" ", bytesToLimit),
)
if status != endpoint.acceptedStatus {
t.Errorf(
"body of exactly the limit: expected %d, got %d: %v",
endpoint.acceptedStatus, status, result,
)
}
})
}
}
func TestAuthNoCookie(t *testing.T) { func TestAuthNoCookie(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1104,8 +999,6 @@ func TestChannelMessage(t *testing.T) {
} }
} }
// TestMessageMissingBody checks that a PRIVMSG with no body,
// or an empty one, gets ERR_NOTEXTTOSEND.
func TestMessageMissingBody(t *testing.T) { func TestMessageMissingBody(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1120,26 +1013,21 @@ func TestMessageMissingBody(t *testing.T) {
_, lastID := tserver.pollMessages(token, 0) _, lastID := tserver.pollMessages(token, 0)
for _, request := range []map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
{commandKey: privmsgCmd, toKey: channel}, commandKey: privmsgCmd, toKey: channel,
{commandKey: privmsgCmd, toKey: channel, bodyKey: []string{}}, })
} {
status, _ := tserver.sendCommand(token, request)
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("%v: expected 200, got %d", request, status) t.Fatalf("expected 200, got %d", status)
} }
var msgs []map[string]any msgs, _ := tserver.pollMessages(token, lastID)
msgs, lastID = tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") { if !findNumeric(msgs, "412") {
t.Fatalf( t.Fatalf(
"%v: expected ERR_NOTEXTTOSEND (412), got %v", "expected ERR_NOTEXTTOSEND (412), got %v",
request, msgs, msgs,
) )
} }
}
} }
func TestMessageMissingTo(t *testing.T) { func TestMessageMissingTo(t *testing.T) {
@@ -1168,132 +1056,6 @@ func TestMessageMissingTo(t *testing.T) {
} }
} }
// TestMessageInvalidBodyOrMeta checks that a PRIVMSG or
// NOTICE, to a channel or a nick, whose body is not an array
// of strings or whose meta is not an object gets 400 and
// reaches nobody.
func TestMessageInvalidBodyOrMeta(t *testing.T) {
t.Parallel()
cases := []struct {
name string
fields map[string]any
}{
{"null line", map[string]any{bodyKey: []any{"hi", nil}}},
{"number line", map[string]any{bodyKey: []any{"hi", 1}}},
{"object line", map[string]any{bodyKey: []any{map[string]any{}}}},
{"null body", map[string]any{bodyKey: nil}},
{"object body", map[string]any{bodyKey: map[string]any{}}},
{"null meta", map[string]any{bodyKey: []string{"hi"}, metaKey: nil}},
{"array meta", map[string]any{bodyKey: []string{"hi"}, metaKey: []string{}}},
{"string meta", map[string]any{bodyKey: []string{"hi"}, metaKey: "hi"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
const channel = "#badmsg"
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_badmsg")
bobToken := tserver.createSession("bob_badmsg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, bobLastID := tserver.pollMessages(bobToken, 0)
for _, command := range []string{privmsgCmd, irc.CmdNotice} {
for _, target := range []string{channel, "bob_badmsg"} {
request := map[string]any{
commandKey: command, toKey: target,
}
maps.Copy(request, tc.fields)
status, result := tserver.sendCommand(
aliceToken, request,
)
if status != http.StatusBadRequest ||
result["error"] != "invalid request body" {
t.Errorf(
"%s to %s: expected 400 invalid "+
"request body, got %d: %v",
command, target, status, result,
)
}
}
}
msgs, _ := tserver.pollMessages(bobToken, bobLastID)
if len(msgs) != 0 {
t.Fatalf("expected no messages, bob got %v", msgs)
}
})
}
}
// TestMessageRelaysBodyAndMeta checks that a PRIVMSG or
// NOTICE, to a channel or a nick, reaches its recipient with
// body and meta as sent.
func TestMessageRelaysBodyAndMeta(t *testing.T) {
t.Parallel()
const channel = "#relaymsg"
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_relaymsg")
bobToken := tserver.createSession("bob_relaymsg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, bobLastID := tserver.pollMessages(bobToken, 0)
body := []any{"line one", ""}
meta := map[string]any{"sig": "abc", "alg": "ed25519"}
for _, command := range []string{privmsgCmd, irc.CmdNotice} {
for _, target := range []string{channel, "bob_relaymsg"} {
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: command, toKey: target,
bodyKey: body, metaKey: meta,
},
)
if status != http.StatusOK {
t.Fatalf(
"%s to %s: expected 200, got %d: %v",
command, target, status, result,
)
}
var msgs []map[string]any
msgs, bobLastID = tserver.pollMessages(
bobToken, bobLastID,
)
if len(msgs) != 1 ||
msgs[0][commandKey] != command ||
!reflect.DeepEqual(msgs[0][bodyKey], body) ||
!reflect.DeepEqual(msgs[0][metaKey], meta) {
t.Fatalf(
"%s to %s: bob got %v", command, target, msgs,
)
}
}
}
}
func TestNonMemberCannotSend(t *testing.T) { func TestNonMemberCannotSend(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1765,7 +1527,9 @@ func checkQuitRace(
firstToken := tserver.createSession(nick) firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver") observerToken := tserver.createSession("raceobserver")
tserver.setPassword(nick) tserver.sendCommand(firstToken, map[string]any{
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
tokens := []string{firstToken, tserver.login(nick)} tokens := []string{firstToken, tserver.login(nick)}
@@ -1815,7 +1579,9 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) {
firstToken := tserver.createSession(nick) firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("logoutobserver") observerToken := tserver.createSession("logoutobserver")
tserver.setPassword(nick) tserver.sendCommand(firstToken, map[string]any{
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
secondToken := tserver.login(nick) secondToken := tserver.login(nick)
@@ -1885,12 +1651,15 @@ func TestFailedLogoutChangesNothing(t *testing.T) {
_, lastID := tserver.pollMessages(observerToken, 0) _, lastID := tserver.pollMessages(observerToken, 0)
dbtest.Exec(t, tserver.dbURL, _, err := tserver.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER keep_sessions `CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions BEFORE DELETE ON sessions
BEGIN BEGIN
SELECT RAISE(ABORT, 'sessions are kept'); SELECT RAISE(ABORT, 'sessions are kept');
END`) END`)
if err != nil {
t.Fatal(err)
}
resp, err := doRequestAuth( resp, err := doRequestAuth(
t, http.MethodPost, tserver.url(apiLogout), token, nil, t, http.MethodPost, tserver.url(apiLogout), token, nil,
@@ -2124,24 +1893,6 @@ func (tserver *testServer) longPoll(
return result.Messages, nil return result.Messages, nil
} }
// setPassword gives the session nick the password testPassword
// without sending PASS. It hashes at bcrypt.MinCost, and a login
// compares at the cost stored in the hash, so both are fast.
func (tserver *testServer) setPassword(nick string) {
tserver.t.Helper()
hash, err := bcrypt.GenerateFromPassword(
[]byte(testPassword), bcrypt.MinCost,
)
if err != nil {
tserver.t.Fatalf("hash password: %v", err)
}
dbtest.Exec(tserver.t, tserver.dbURL,
"UPDATE sessions SET password_hash = '"+string(hash)+
"' WHERE nick = '"+nick+"'")
}
// login logs in to the session nick with testPassword and // login logs in to the session nick with testPassword and
// returns the new client's token. // returns the new client's token.
func (tserver *testServer) login(nick string) string { func (tserver *testServer) login(nick string) string {
@@ -3749,8 +3500,13 @@ func TestLoginValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("loginuser") // Create session and set password via PASS command.
tserver.setPassword("loginuser") token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
// Login with nick + password. // Login with nick + password.
loginBody, err := json.Marshal(map[string]string{ loginBody, err := json.Marshal(map[string]string{
@@ -3814,8 +3570,13 @@ func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("wrongpwuser") // Create session and set password via PASS command.
tserver.setPassword("wrongpwuser") token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"correctpass1"},
})
postJSONExpectStatus( postJSONExpectStatus(
t, tserver, "/api/v1/login", t, tserver, "/api/v1/login",
@@ -4143,31 +3904,6 @@ func assertWhoReplyUsername(
) )
} }
func TestWhoUnknownChannelEndReplyAddsHash(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("whoasker")
_, lastID := tserver.pollMessages(token, 0)
msgs, _ := sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdWho,
toKey: "nosuch",
})
endOfWho := findNumericWithParams(msgs, "315")
if endOfWho == nil {
t.Fatalf("expected RPL_ENDOFWHO (315), got %v", msgs)
}
params := getNumericParams(endOfWho)
if !slices.Equal(params, []string{"#nosuch"}) {
t.Fatalf("expected params [#nosuch], got %v", params)
}
}
func TestSessionUsernameDefault(t *testing.T) { func TestSessionUsernameDefault(t *testing.T) {
t.Parallel() t.Parallel()
@@ -4286,8 +4022,13 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("normaluser") // Create session and set password via PASS command.
tserver.setPassword("normaluser") token := tserver.createSession("normaluser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
// A single login should succeed without rate limiting. // A single login should succeed without rate limiting.
loginBody, err := json.Marshal(map[string]string{ loginBody, err := json.Marshal(map[string]string{
@@ -7056,32 +6797,6 @@ func TestISUPPORT(t *testing.T) {
} }
} }
// TestMyInfoListsModes verifies the 004 numeric lists the
// user modes o and w as its third parameter, and the HTTP
// API's channel modes Hbiklmnostv as its fourth.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("myinfo")
msgs, _ := tserver.pollMessages(token, 0)
myInfo := findNumericWithParams(msgs, "004")
if myInfo == nil {
t.Fatal("expected 004 RPL_MYINFO")
}
params := getNumericParams(myInfo)
if len(params) != 4 || params[2] != "ow" ||
params[3] != "Hbiklmnostv" {
t.Fatalf(
"004 params %v, want four ending ow Hbiklmnostv",
params,
)
}
}
// TestNonOpCannotSetModes verifies non-operators // TestNonOpCannotSetModes verifies non-operators
// cannot set +i, +s, +k, +l, +b. // cannot set +i, +s, +k, +l, +b.
func TestNonOpCannotSetModes(t *testing.T) { func TestNonOpCannotSetModes(t *testing.T) {
+2 -1
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -54,7 +55,7 @@ func (hdlr *Handlers) handleLogin(
var payload loginRequest var payload loginRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
-13
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@@ -160,18 +159,6 @@ func (hdlr *Handlers) respondError(
) )
} }
// decodeJSON reads the whole request body and decodes it into dest.
// It fails if the body is larger than the http.MaxBytesReader limit,
// or holds anything other than one JSON value and whitespace.
func decodeJSON(request *http.Request, dest any) error {
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
return json.Unmarshal(body, dest)
}
func (hdlr *Handlers) idleTimeout() time.Duration { func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" { if raw == "" {
+2 -6
View File
@@ -371,7 +371,6 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock() c.mu.Lock()
c.closed = true c.closed = true
c.quitReason = reason
c.mu.Unlock() c.mu.Unlock()
} }
@@ -1227,12 +1226,9 @@ func (c *Conn) handlePassPostReg(
return return
} }
// The IRC listener cannot add a connection to an existing
// session; only the HTTP API login can.
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.currentNick(),
"Password set. Another client can log in to this "+ "Password set. You can reconnect using "+
"session over the HTTP API: POST /api/v1/login "+ "PASS <password> with your nick.",
"with your nick and this password.",
) )
} }
+4 -9
View File
@@ -69,11 +69,6 @@ type Conn struct {
lastQueueID int64 lastQueueID int64
closed bool closed bool
killed bool killed bool
// quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT
// replaces it.
quitReason string
} }
func newConn( func newConn(
@@ -100,7 +95,6 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
quitReason: "Connection closed",
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -284,7 +278,6 @@ func (c *Conn) cleanup(ctx context.Context) {
wasKilled := c.killed wasKilled := c.killed
sessID := c.sessionID sessID := c.sessionID
nick := c.nick nick := c.nick
quitReason := c.quitReason
c.closed = true c.closed = true
c.mu.Unlock() c.mu.Unlock()
@@ -294,7 +287,9 @@ func (c *Conn) cleanup(ctx context.Context) {
// KILL has already sent the QUIT and deleted the // KILL has already sent the QUIT and deleted the
// session. // session.
if !wasKilled { if !wasKilled {
c.svc.BroadcastQuit(ctx, sessID, nick, quitReason) c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
} }
} }
@@ -610,7 +605,7 @@ func (c *Conn) deliverWelcome() {
) )
c.sendNumeric( c.sendNumeric(
irc.RplMyInfo, irc.RplMyInfo,
c.serverSfx, "neoirc", service.UserModes, "Himnostv", c.serverSfx, "neoirc", "", "mnst",
) )
c.sendNumeric( c.sendNumeric(
irc.RplIsupport, irc.RplIsupport,
+5 -18
View File
@@ -8,8 +8,6 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
@@ -318,7 +316,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
{"JOIN #case", ":Alice!Alice@* JOIN #case"}, {"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"}, {"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT {"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Client quit"}, {"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} { } {
capitalAlice.send(step.line) capitalAlice.send(step.line)
@@ -585,10 +583,8 @@ func ownSessionCopies(
// aliceWithHTTPClient connects alice to the IRC listener and // aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API // has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with a password, as POST /api/v1/login // client to her session with the password her connection set,
// does. It stores that password without sending PASS. It hashes // as POST /api/v1/login does. It returns her connection, bob's
// at bcrypt.MinCost, and a login compares at the cost stored in
// the hash, so both are fast. It returns her connection, bob's
// connection, her session and her HTTP API client. // connection, her session and her HTTP API client.
func aliceWithHTTPClient( func aliceWithHTTPClient(
t *testing.T, env *testEnv, t *testing.T, env *testEnv,
@@ -598,24 +594,15 @@ func aliceWithHTTPClient(
const password = "alicepass" const password = "alicepass"
alice := env.dial(t) alice := env.dial(t)
alice.send("PASS " + password)
alice.register("alice") alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan") alice.joinAndDrain("#chan")
bob := env.dial(t) bob := env.dial(t)
bob.register("bob") bob.register("bob")
bob.joinAndDrain("#chan") bob.joinAndDrain("#chan")
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.MinCost,
)
if err != nil {
t.Fatalf("hash password: %v", err)
}
dbtest.Exec(t, env.dbURL,
"UPDATE sessions SET password_hash = '"+string(hash)+
"' WHERE nick = 'alice'")
sessionID, httpClientID, _, err := env.database.LoginUser( sessionID, httpClientID, _, err := env.database.LoginUser(
t.Context(), "alice", password, "127.0.0.1", "localhost", t.Context(), "alice", password, "127.0.0.1", "localhost",
) )
+45 -161
View File
@@ -4,14 +4,12 @@ import (
"bufio" "bufio"
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -25,16 +23,20 @@ import (
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const testTimeout = 5 * time.Second const testTimeout = 5 * time.Second
func TestMain(m *testing.M) {
db.SetBcryptCost(4)
os.Exit(m.Run())
}
// testEnv holds the shared test infrastructure. // testEnv holds the shared test infrastructure.
type testEnv struct { type testEnv struct {
dbURL string
database *db.Database database *db.Database
brk *broker.Broker brk *broker.Broker
cfg *config.Config cfg *config.Config
@@ -105,26 +107,33 @@ func newTestEnvWithResolver(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
dbURL := dbtest.NewURL(t) conn, err := sql.Open("sqlite", dbtest.NewURL(t))
lifecycle := fxtest.NewLifecycle(t)
dbLog, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil { if err != nil {
t.Fatalf("new logger: %v", err) t.Fatalf("open db: %v", err)
} }
database, err := db.New(lifecycle, db.Params{ t.Cleanup(func() {
Logger: dbLog, err := conn.Close()
Config: &config.Config{DBURL: dbURL},
})
if err != nil { if err != nil {
t.Fatalf("new database: %v", err) t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
} }
lifecycle.RequireStart() database := db.NewTestDatabaseFromConn(conn)
t.Cleanup(lifecycle.RequireStop)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New() brk := broker.New()
@@ -149,7 +158,6 @@ func newTestEnvWithResolver(
t.Cleanup(srv.Stop) t.Cleanup(srv.Stop)
return &testEnv{ return &testEnv{
dbURL: dbURL,
database: database, database: database,
brk: brk, brk: brk,
cfg: cfg, cfg: cfg,
@@ -478,22 +486,6 @@ func TestWelcomeContainsNick(t *testing.T) {
} }
} }
// TestMyInfoListsModes checks that RPL_MYINFO lists the user
// modes o and w, then the channel modes MODE accepts on the
// IRC listener. It compares the whole line, so a missing or
// extra mode fails it.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
lines := env.dial(t).register("carol")
want := ":test.irc 004 carol test.irc neoirc ow Himnostv"
if !slices.Contains(lines, want) {
t.Errorf("no line %q in %v", want, lines)
}
}
func TestPingPong(t *testing.T) { func TestPingPong(t *testing.T) {
t.Parallel() t.Parallel()
@@ -864,107 +856,6 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestQuitReasonReachesPeers checks the reason that an IRC
// listener user's channel peers see in its QUIT, on both the
// IRC listener and the HTTP API. It is the reason the user
// gave, Client quit for a QUIT without one, or Connection
// closed when the connection ends without a QUIT.
func TestQuitReasonReachesPeers(t *testing.T) {
t.Parallel()
for _, step := range []struct {
name, quit, want string
}{
{"QUIT with a reason", "QUIT :gone home", "gone home"},
{"QUIT without a reason", irc.CmdQuit, "Client quit"},
{"connection closed without QUIT", "", "Connection closed"},
} {
t.Run(step.name, func(t *testing.T) {
t.Parallel()
ircLine, apiBody := quitSeenByPeers(t, step.quit)
want := ":quitter!quitter@* QUIT :Quit: " + step.want
if ircLine != want {
t.Errorf("IRC peer got %q, want %q", ircLine, want)
}
if len(apiBody) != 1 || apiBody[0] != step.want {
t.Errorf("HTTP API peer got %q, want [%q]", apiBody, step.want)
}
})
}
}
// quitSeenByPeers starts a server on which quitter, ircpeer on
// the IRC listener and apipeer on the HTTP API share a channel.
// quitter sends the line quit, or closes its connection when
// quit is empty. It returns the QUIT line ircpeer is sent and
// the body of the QUIT apipeer is sent.
func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
ircPeer := env.dial(t)
ircPeer.register("ircpeer")
ircPeer.joinAndDrain("#bye")
apiPeerID, apiPeerClientID, _, err := env.database.CreateSession(
ctx, "apipeer", "apipeer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
_, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "")
if err != nil {
t.Fatalf("join peer: %v", err)
}
quitter := env.dial(t)
quitter.register("quitter")
quitter.joinAndDrain("#bye")
if quit == "" {
err = quitter.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
} else {
quitter.send(quit)
}
lines := ircPeer.readUntil(func(l string) bool {
return strings.Contains(l, " QUIT ")
})
// The QUIT is queued to both peers at once, so apipeer has
// it by now.
msgs, _, err := env.database.PollMessages(ctx, apiPeerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == irc.CmdQuit && msg.From == "quitter" {
var body []string
err = json.Unmarshal(msg.Body, &body)
if err != nil {
t.Fatalf("decode QUIT body %s: %v", msg.Body, err)
}
return lines[len(lines)-1], body
}
}
t.Fatalf("apipeer was not sent the QUIT: %v", msgs)
return "", nil
}
// TestStopEndsSessions checks that stopping the server ends // TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as // a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and // closing the connection does: the session, its client and
@@ -989,9 +880,12 @@ func TestStopEndsSessions(t *testing.T) {
t.Fatalf("create peer session: %v", err) t.Fatalf("create peer session: %v", err)
} }
channelID, _, err := env.database.CreateOrJoinChannel( channelID, err := env.database.GetChannelByName(ctx, "#shut")
ctx, "#shut", peerID, "", if err != nil {
) t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil { if err != nil {
t.Fatalf("join peer: %v", err) t.Fatalf("join peer: %v", err)
} }
@@ -1049,16 +943,11 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
client.register("stuck") client.register("stuck")
// While a connection of the test's own holds a write // The test database has one connection. While the test
// transaction, the cleanup's first write waits for it to end. // holds it, the cleanup's first query waits for it.
holder, err := sql.Open("sqlite", env.dbURL) held, err := env.database.GetDB().Conn(t.Context())
if err != nil { if err != nil {
t.Fatalf("open a second database connection: %v", err) t.Fatalf("take the database connection: %v", err)
}
_, err = holder.ExecContext(t.Context(), "BEGIN IMMEDIATE")
if err != nil {
t.Fatalf("begin a write transaction: %v", err)
} }
stopped := make(chan struct{}) stopped := make(chan struct{})
@@ -1074,11 +963,10 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Error("Stop did not return while a cleanup was stuck") t.Error("Stop did not return while a cleanup was stuck")
} }
// Closing ends the transaction, which lets the cleanup finish // Lets the cleanup finish before the database is closed.
// before the database is closed. err = held.Close()
err = holder.Close()
if err != nil { if err != nil {
t.Errorf("close the second database connection: %v", err) t.Errorf("release the database connection: %v", err)
} }
} }
@@ -1237,9 +1125,6 @@ func TestAwaySetAndClear(t *testing.T) {
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY") assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
} }
// TestHandlePassPostRegistration checks the NOTICE that
// confirms PASS after registration. It points to the HTTP API
// login, the only way to add a client to an existing session.
func TestHandlePassPostRegistration(t *testing.T) { func TestHandlePassPostRegistration(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1252,11 +1137,7 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
assertContains( assertContains(
t, lines, t, lines, "Password set", "password confirmation",
" NOTICE passuser :Password set. Another client can log "+
"in to this session over the HTTP API: "+
"POST /api/v1/login with your nick and this password.",
"password confirmation",
) )
} }
@@ -1357,11 +1238,14 @@ func TestPassPostRegistrationNotStored(t *testing.T) {
client.register("failpass") client.register("failpass")
dbtest.Exec(t, env.dbURL, _, err := env.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER refuse_password `CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`, BEGIN SELECT RAISE(ABORT, 'refused'); END`,
) )
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123") client.send("PASS :mypassword123")
-6
View File
@@ -1169,12 +1169,6 @@ func (s *Service) ApplyUserMode(
return s.QueryUserMode(ctx, sessionID) return s.QueryUserMode(ctx, sessionID)
} }
// UserModes lists the user modes the server supports, as
// RPL_MYINFO sends them: o (server operator) and w
// (receives WALLOPS). parseUserModeString must accept the
// same letters.
const UserModes = "ow"
// parseUserModeString parses a user mode string such as // parseUserModeString parses a user mode string such as
// "+w", "-o" or "+w-o" into the new value of each flag it // "+w", "-o" or "+w-o" into the new value of each flag it
// names, or nil for a flag it does not name; a later letter // names, or nil for a flag it does not name; a later letter
+16 -5
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -17,6 +18,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -28,10 +30,14 @@ import (
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
// testEnv holds all dependencies for a service test. // testEnv holds all dependencies for a service test.
type testEnv struct { type testEnv struct {
svc *service.Service svc *service.Service
dbURL string
db *db.Database db *db.Database
broker *broker.Broker broker *broker.Broker
app *fxtest.App app *fxtest.App
@@ -105,7 +111,6 @@ func newTestEnv(t *testing.T) *testEnv {
return &testEnv{ return &testEnv{
svc: svc, svc: svc,
dbURL: dbURL,
db: database, db: database,
broker: brk, broker: brk,
app: app, app: app,
@@ -935,16 +940,19 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
createSession(ctx, t, env.db, "bob") createSession(ctx, t, env.db, "bob")
dbtest.Exec(t, env.dbURL, _, err := env.db.GetDB().ExecContext(ctx,
`CREATE TRIGGER delete_recipient `CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages BEFORE INSERT ON messages
BEGIN BEGIN
DELETE FROM sessions WHERE id = NEW.to_session_id; DELETE FROM sessions WHERE id = NEW.to_session_id;
END`) END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err := env.svc.SendDirectMessage( _, err = env.svc.SendDirectMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil,
) )
@@ -970,12 +978,15 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
dbtest.Exec(t, env.dbURL, _, err = env.db.GetDB().ExecContext(ctx,
`CREATE TRIGGER delete_recipient `CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages BEFORE INSERT ON messages
BEGIN BEGIN
DELETE FROM channels WHERE id = NEW.to_channel_id; DELETE FROM channels WHERE id = NEW.to_channel_id;
END`) END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
+82 -17
View File
@@ -20,7 +20,7 @@ are the fields it can have:
- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent - `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent
on other messages. on other messages.
- `id`: the message's UUID, a unique ID that the server assigns. - `id`: the message's UUID, a unique ID that the server assigns.
- `from`: the sender's nick, or the server name on a numeric reply or a `PONG`. - `from`: the sender's nick, or the server name on a server message.
- `to`: the target, such as a channel name starting with `#` or a nick. - `to`: the target, such as a channel name starting with `#` or a nick.
- `params`: an array of strings with the message's other parameters, in order. - `params`: an array of strings with the message's other parameters, in order.
`KICK` events and many numeric replies have it. The HTTP API ignores `params` `KICK` events and many numeric replies have it. The HTTP API ignores `params`
@@ -60,32 +60,29 @@ A `PRIVMSG` whose `body` has two strings is a two-line message:
## Files ## Files
Most command files describe both the message a client sends and the message the
server sends. Their field descriptions say where the two differ. The examples in
the files leave out `id`, `ts` and `meta`.
Commands, in `commands/`: Commands, in `commands/`:
- `PRIVMSG.json`: a message to a channel or a user. - `PRIVMSG.json`: a message to a channel or a user.
- `NOTICE.json`: a notice, which is a message that a client must not answer - `NOTICE.json`: a notice, which is a message that a client must not answer
automatically. automatically.
- `JOIN.json`: joining a channel. - `JOIN.json`: a user joined a channel.
- `PART.json`: leaving a channel. - `PART.json`: a user left a channel.
- `QUIT.json`: leaving the server. - `QUIT.json`: a user left the server.
- `NICK.json`: changing nick. - `NICK.json`: a user changed nick.
- `TOPIC.json`: setting a channel's topic. - `TOPIC.json`: a channel topic was set.
- `MODE.json`: querying or changing a channel's modes or the user's own modes. - `MODE.json`: a channel mode was changed.
- `KICK.json`: a channel operator removing a user from a channel. - `KICK.json`: a channel operator removed a user from a channel.
- `PING.json`: a check that the server still answers. - `PING.json`: a check that the server still answers.
- `PONG.json`: the answer to a `PING`, which is the HTTP response to it. - `PONG.json`: the answer to a `PING`.
- `PUBKEY.json`: an announcement of a user's public signing key. The server has
no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`).
Numeric replies, in `numerics/`: Numeric replies, in `numerics/`:
- `001.json`: `RPL_WELCOME`, which welcomes the user. - `001.json`: `RPL_WELCOME`, which welcomes a new user.
- `002.json`: `RPL_YOURHOST`, the server's name and version. - `002.json`: `RPL_YOURHOST`, the server's name and version.
- `003.json`: `RPL_CREATED`, the date the server last started. - `003.json`: `RPL_CREATED`, which says when the server was created.
- `004.json`: `RPL_MYINFO`, the server's name, its version and its lists of - `004.json`: `RPL_MYINFO`, the server's version and its lists of modes.
modes.
- `322.json`: `RPL_LIST`, one channel in the answer to `LIST`. - `322.json`: `RPL_LIST`, one channel in the answer to `LIST`.
- `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`. - `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`.
- `332.json`: `RPL_TOPIC`, a channel's topic. - `332.json`: `RPL_TOPIC`, a channel's topic.
@@ -99,3 +96,71 @@ Numeric replies, in `numerics/`:
- `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken. - `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken.
- `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel. - `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel.
- `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator. - `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator.
## Where the files differ from the HTTP API
Where a schema file and the HTTP API disagree, the HTTP API is right:
- `message.json` and the files in `numerics/` put the three-digit code in
`command`. They have no `code` field. The HTTP API puts the reply's name in
`command` and its number in `code`.
- `message.json` allows an object as `body`. The HTTP API reads only arrays of
strings. A `PRIVMSG` with an object as `body` gets `ERR_NOTEXTTOSEND` (`412`).
- `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*`
there.
- `message.json` allows `meta` on every message. The HTTP API keeps a client's
`meta` only on a `PRIVMSG` or `NOTICE`.
- `message.json` describes signature fields in `meta`. The server never checks a
signature.
- `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a
`PRIVMSG` to a channel with mode `+H`.
- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in
`body`. When a user joins, the `JOIN` event repeats the channel name in
`body`.
- `JOIN.json` allows a channel name of any length. The server allows at most 63
characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL`
(`403`).
- `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's
`KICK` from `body`: the nick, then an optional reason.
- `MODE.json` puts the mode change in `params`. The HTTP API uses `body`
instead. A client sends the change and any value as separate strings, such as
`"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string,
such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`.
- `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with
`ERR_NOSUCHNICK` (`401`).
- `NOTICE.json` has an example notice from the server to `*`. The server sends
no such notice.
- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP
API ignores the `body` of a `PING`. Its answer is the HTTP response
`{"command": "PONG", "from": "<server name>"}`.
- `PING.json` and `PONG.json` also describe messages between servers. The server
does not connect to other IRC servers.
- `PUBKEY.json` describes a command the server does not have.
- `QUIT.json` says that `QUIT` comes only from the server. A client can send it
too, to end its session. A client's `QUIT` can carry a reason in `body`.
- `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API
answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`).
- `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]`
with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic.
- `003.json` describes the server's creation date. The HTTP API sends the date
the server last started.
- `004.json` has example mode lists. The HTTP API sends an empty string as the
user modes and `ikmnostl` as the channel modes.
- `322.json` says `LIST` gets one reply per channel. The server leaves out a
secret channel, one with mode `+s`, unless the user is on it.
- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such
query. It sends `RPL_TOPIC` after a client sets the topic, and after a `JOIN`
to a channel that has one.
- `353.json` says `body` lists nicks. The HTTP API lists `nick!user@host` in
place of each nick, such as `@op1!op1@host1`.
- `353.json` says the first parameter marks a channel as public, private or
secret. The server always sends `=`, the mark for a public channel.
- `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full
stop.
- `401.json` gives the text as `No such nick/channel`. The server sends
`No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick.
- `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in
answer to a `NICK`, with the user's nick as `to`.
The HTTP API also accepts some messages that the files do not allow, such as a
`JOIN` whose `to` has no `#`.
+4 -14
View File
@@ -2,25 +2,15 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
"title": "JOIN", "title": "JOIN",
"description": "Join a channel. A client sends it to join a channel, which the server creates if it does not exist. The server sends it to every member of the channel when a user joins, the new member included. RFC 1459 §4.2.1.", "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "JOIN" }, "command": { "const": "JOIN" },
"from": { "type": "string", "description": "The nick that joined. The server sets it." }, "from": { "type": "string", "description": "Nick that joined (S2C)." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }
"type": "string",
"description": "The channel name: # and then 1 to 63 of a-z, A-Z, 0-9, _ and -. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "From a client: the channel key as the first string, which a channel with mode +k needs. From the server: the channel name. After a login, and for GET /api/v1/state?initChannelState=1, the server queues a JOIN for each of the session's channels. That JOIN has an empty body."
}
}, },
"required": ["command", "to"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "JOIN", "to": "#general" }, { "command": "JOIN", "from": "alice", "to": "#general" }
{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] }
] ]
} }
+7 -12
View File
@@ -2,33 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK", "title": "KICK",
"description": "Remove a user from a channel. A client sends it, and only a channel operator may. The server sends it to every member of the channel, the removed user included. RFC 1459 §4.2.8.", "description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "KICK" }, "command": { "const": "KICK" },
"from": { "type": "string", "description": "The nick that removed the user. The server sets it." }, "from": { "type": "string", "description": "Nick that performed the kick." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"params": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The removed nick. Only the server sends it.", "description": "Kicked nick. e.g. [\"alice\"].",
"minItems": 1, "minItems": 1,
"maxItems": 1 "maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "From a client: the nick to remove, then an optional reason. From the server: the reason, as one string.", "description": "Optional kick reason.",
"minItems": 1 "maxItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to", "params"],
"examples": [ "examples": [
{ "command": "KICK", "to": "#general", "body": ["troll", "Behave"] },
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] } { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] }
] ]
} }
+9 -10
View File
@@ -2,29 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE", "title": "MODE",
"description": "Query or change a channel's modes or the user's own modes. A client sends it. The server sends it to every member of the channel when a channel mode changes. A +H or -H sent over the HTTP API is the exception. The server answers it with RPL_CHANNELMODEIS (324) and sends no MODE. RFC 1459 §4.2.3.", "description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "MODE" }, "command": { "const": "MODE" },
"from": { "from": {
"type": "string", "type": "string",
"description": "The nick that changed the mode. The server sets it." "description": "Nick that set the mode (S2C only)."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "A channel name, which starts with #, or the user's own nick." "description": "Channel name.",
"pattern": "^#[a-zA-Z0-9_-]+$"
}, },
"body": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "From a client: the mode change and then its value, as separate strings, such as \"+o\" and \"bob\". A MODE without a body is a query. From the server: the change and its value, as one string, such as \"+o bob\", or as separate strings, such as \"+k\" and \"secret\".", "description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].",
"examples": [["+o", "bob"], ["-m"], ["+o bob"], ["+k", "secret"]] "examples": [["+o", "alice"], ["-m"], ["+v", "bob"]]
} }
}, },
"required": ["command", "to"], "required": ["command", "to", "params"],
"examples": [ "examples": [
{ "command": "MODE", "to": "#general", "body": ["+o", "bob"] }, { "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] }
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+o bob"] },
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+k", "secret"] }
] ]
} }
+5 -5
View File
@@ -2,21 +2,21 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
"title": "NICK", "title": "NICK",
"description": "Change nick. A client sends it to change its nick. The server sends it to the user's own clients and to the users who share a channel with the user. RFC 1459 §4.1.2.", "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "NICK" }, "command": { "const": "NICK" },
"from": { "type": "string", "description": "The old nick. The server sets it." }, "from": { "type": "string", "description": "Old nick (S2C)." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The new nick, as the first string. The server sends one string.", "description": "New nick (single-element array).",
"minItems": 1 "minItems": 1,
"maxItems": 1
} }
}, },
"required": ["command", "body"], "required": ["command", "body"],
"examples": [ "examples": [
{ "command": "NICK", "body": ["newnick"] },
{ "command": "NICK", "from": "oldnick", "body": ["newnick"] } { "command": "NICK", "from": "oldnick", "body": ["newnick"] }
] ]
} }
+5 -6
View File
@@ -2,21 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE", "title": "NOTICE",
"description": "A notice: a message to a channel or a user that a client must not answer automatically. The server relays it as it relays a PRIVMSG. A NOTICE to a channel with mode +H needs no hashcash stamp. RFC 1459 §4.4.2.", "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "NOTICE" }, "command": { "const": "NOTICE" },
"from": { "type": "string", "description": "The sender's nick. The server sets it." }, "from": { "type": "string" },
"to": { "type": "string", "description": "A channel name, which starts with #, or a nick." }, "to": { "type": "string", "description": "Target: #channel, nick, or * (global)." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The lines of the notice, one string per line.", "description": "Notice text lines."
"minItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to", "body"],
"examples": [ "examples": [
{ "command": "NOTICE", "from": "alice", "to": "#general", "body": ["Back in five minutes"] } { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] }
] ]
} }
+5 -8
View File
@@ -2,20 +2,17 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
"title": "PART", "title": "PART",
"description": "Leave a channel. A client sends it to leave. The server sends it to every member of the channel when a user leaves, the leaving user included. RFC 1459 §4.2.2.", "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PART" }, "command": { "const": "PART" },
"from": { "type": "string", "description": "The nick that left. The server sets it." }, "from": { "type": "string", "description": "Nick that left (S2C)." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The reason for leaving. A client may send one as the first string. The server always sends one string, which is empty when no reason was given." "description": "Optional part reason.",
"maxItems": 1
} }
}, },
"required": ["command", "to"], "required": ["command", "to"],
+9 -3
View File
@@ -2,13 +2,19 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING", "title": "PING",
"description": "A check that the server still answers. A client sends it. The server ignores every field but 'command'. Its answer is a PONG in the HTTP response. RFC 1459 §4.6.2.", "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PING" } "command": { "const": "PING" },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Opaque token to be echoed in PONG (single-element array).",
"maxItems": 1
}
}, },
"required": ["command"], "required": ["command"],
"examples": [ "examples": [
{ "command": "PING" } { "command": "PING", "body": ["1707580000"] }
] ]
} }
+10 -4
View File
@@ -2,14 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG", "title": "PONG",
"description": "The answer to a PING. The server sends it as the HTTP response to the PING, with only 'command' and 'from'. A client's PONG gets ERR_UNKNOWNCOMMAND (421). RFC 1459 §4.6.3.", "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PONG" }, "command": { "const": "PONG" },
"from": { "type": "string", "description": "The server name." } "from": { "type": "string", "description": "Responding server name." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Echoed token from PING (single-element array).",
"maxItems": 1
}
}, },
"required": ["command", "from"], "required": ["command"],
"examples": [ "examples": [
{ "command": "PONG", "from": "neoirc" } { "command": "PONG", "from": "server.example.com", "body": ["1707580000"] }
] ]
} }
+5 -5
View File
@@ -2,16 +2,16 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG", "title": "PRIVMSG",
"description": "A message to a channel or a user. A client sends it. The server relays it to every member of the channel, or to the user, and to the sender's own clients. RFC 1459 §4.4.1.", "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PRIVMSG" }, "command": { "const": "PRIVMSG" },
"from": { "type": "string", "description": "The sender's nick. The server sets it." }, "from": { "type": "string", "description": "Sender nick (set by server on relay)." },
"to": { "type": "string", "description": "A channel name, which starts with #, or a nick.", "examples": ["#general", "alice"] }, "to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The lines of the message, one string per line.", "description": "Message lines. One string per line.",
"minItems": 1 "minItems": 1
} }
}, },
@@ -19,6 +19,6 @@
"examples": [ "examples": [
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] }, { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] },
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] }, { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] },
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"] } { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } }
] ]
} }
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PUBKEY" },
"from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." },
"to": {
"type": "string",
"description": "Target: #channel to announce to channel members, or omit for server-wide announcement."
},
"body": {
"type": "object",
"description": "Key material.",
"properties": {
"alg": {
"type": "string",
"description": "Key algorithm.",
"enum": ["ed25519"]
},
"key": {
"type": "string",
"description": "Base64-encoded public key."
}
},
"required": ["alg", "key"],
"additionalProperties": false
}
},
"required": ["command", "body"],
"examples": [
{ "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } },
{ "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
"title": "QUIT", "title": "QUIT",
"description": "A user left the server. A client sends it to end its session. The server sends it to the users who shared a channel with the user who left. RFC 1459 §4.1.6.", "description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "QUIT" }, "command": { "const": "QUIT" },
"from": { "type": "string", "description": "The nick that quit. The server sets it." }, "from": { "type": "string", "description": "Nick that quit." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The reason for leaving. A client may send one as the first string, and the server uses 'Client quit' when it does not. The server always sends one string." "description": "Optional quit reason.",
"maxItems": 1
} }
}, },
"required": ["command"], "required": ["command", "from"],
"examples": [ "examples": [
{ "command": "QUIT", "body": ["Gone for the day"] }, { "command": "QUIT", "from": "alice", "body": ["Connection reset"] }
{ "command": "QUIT", "from": "alice", "body": ["Connection closed"] }
] ]
} }
+6 -10
View File
@@ -2,24 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC", "title": "TOPIC",
"description": "Set a channel's topic. A client sends it to set the topic. The server sends it to every member of the channel when the topic changes. RFC 1459 §4.2.4.", "description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "TOPIC" }, "command": { "const": "TOPIC" },
"from": { "type": "string", "description": "The nick that set the topic. The server sets it." }, "from": { "type": "string", "description": "Nick that changed the topic (S2C)." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The new topic. The server joins a client's strings with spaces, and sends the topic as one string. [\"\"] clears the topic.", "description": "New topic text (single-element array). Empty array clears the topic.",
"minItems": 1 "maxItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] } { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
] ]
+30 -18
View File
@@ -2,55 +2,67 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json", "$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope", "title": "IRC Message Envelope",
"description": "Every message in the HTTP API is one JSON object with these fields. Only 'command' is always present.", "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"description": "The message's UUID, which the server assigns. Present on every message the server queues for a client." "description": "Server-assigned message UUID. Present on all server-originated messages."
}, },
"command": { "command": {
"type": "string", "type": "string",
"description": "An IRC command name, such as PRIVMSG, or the name of a numeric reply, such as RPL_WELCOME. The server reads a client's command in any case and sends it in upper case.", "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).",
"examples": ["PRIVMSG", "JOIN", "RPL_WELCOME", "ERR_NICKNAMEINUSE"] "examples": ["PRIVMSG", "JOIN", "001", "353", "433"]
},
"code": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"description": "The number of a numeric reply, such as 1 for RPL_WELCOME. Absent on other messages."
}, },
"from": { "from": {
"type": "string", "type": "string",
"description": "The sender's nick, or the server name on a numeric reply or a PONG. The server sets it and ignores it from a client." "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "The target: a channel name, such as #general, or a nick. A WALLOPS has * here." "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The message's other parameters, in order. KICK events and many numeric replies have it. The server ignores it from a client." "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters."
}, },
"body": { "body": {
"oneOf": [
{
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "An array of strings, never a bare string. In a PRIVMSG or NOTICE, each string is one line of text." "description": "Array of strings (one per line for text messages)."
},
{
"type": "object",
"description": "Structured data (e.g. PUBKEY key material).",
"additionalProperties": true
}
],
"description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing."
}, },
"ts": { "ts": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"description": "The time the server stored the message, as an RFC 3339 time in UTC." "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport."
}, },
"meta": { "meta": {
"type": "object", "type": "object",
"description": "An object of extra data. Every message the server queues for a client has meta, which is {} when there is none. The server keeps a client's meta only on a PRIVMSG or NOTICE, and relays it unchanged.", "description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.",
"properties": { "properties": {
"hashcash": { "sig": {
"type": "string", "type": "string",
"description": "A hashcash stamp. A PRIVMSG to a channel with mode +H needs one." "description": "Base64-encoded cryptographic signature over the canonical message form."
},
"alg": {
"type": "string",
"description": "Signature algorithm (e.g. 'ed25519')."
},
"hash": {
"type": "string",
"description": "Hash of the canonical message form (e.g. 'sha256:base64...')."
} }
}, },
"additionalProperties": true "additionalProperties": true
+5 -18
View File
@@ -2,45 +2,32 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME", "title": "001 RPL_WELCOME",
"description": "Welcomes the user. Sent after a session is created, after a login, and in answer to MOTD. RFC 2812 §5.1.", "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_WELCOME" "const": "001"
},
"code": {
"const": 1
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "The user's nick." "description": "Target nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'Welcome to the network, ' and the user's nick.", "description": "Welcome text lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_WELCOME", "command": "001",
"code": 1,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"Welcome to the network, alice" "Welcome to the network, alice"
+6 -20
View File
@@ -2,48 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST", "title": "002 RPL_YOURHOST",
"description": "The server's name and version. Sent right after RPL_WELCOME. RFC 2812 §5.1.", "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_YOURHOST" "const": "002"
},
"code": {
"const": 2
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'Your host is ', the server name, ', running version ' and the version.", "description": "Host info lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_YOURHOST", "command": "002",
"code": 2,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"Your host is neoirc, running version dev" "Your host is neoirc.example.com, running version 0.1.0"
] ]
} }
] ]
+5 -19
View File
@@ -2,45 +2,31 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED", "title": "003 RPL_CREATED",
"description": "The date the server last started. Sent right after RPL_YOURHOST. RFC 2812 §5.1.", "description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_CREATED" "const": "003"
},
"code": {
"const": 3
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'This server was created ' and the start date as YYYY-MM-DD.", "description": "Text lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_CREATED", "command": "003",
"code": 3,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"This server was created 2026-02-01" "This server was created 2026-02-01"
+10 -37
View File
@@ -2,64 +2,37 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO", "title": "004 RPL_MYINFO",
"description": "The server's name, its version, and its lists of user and channel modes. Sent right after RPL_CREATED. RFC 2812 §5.1.", "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_MYINFO" "const": "004"
},
"code": {
"const": 4
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The server name, the version, the user modes 'ow' and the channel modes 'Hbiklmnostv'.", "description": "[server_name, version, user_modes, channel_modes]."
"minItems": 4,
"maxItems": 4
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One empty string.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_MYINFO", "command": "004",
"code": 4,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"neoirc", "neoirc.example.com",
"dev", "0.1.0",
"ow", "o",
"Hbiklmnostv" "imnst+ov"
],
"body": [
""
] ]
} }
] ]
+7 -24
View File
@@ -2,55 +2,38 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST", "title": "322 RPL_LIST",
"description": "One channel in the answer to LIST. The answer has one per channel, except a secret channel, one with mode +s, that the user is not on. RFC 1459 §6.2.", "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_LIST" "const": "322"
},
"code": {
"const": 322
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name and its number of members.", "description": "[channel, visible_count]."
"minItems": 2,
"maxItems": 2
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: the channel's topic, empty when it has none.", "description": "Channel topic."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_LIST", "command": "322",
"code": 322,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#general", "#general",
+5 -28
View File
@@ -2,49 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND", "title": "323 RPL_LISTEND",
"description": "The end of the answer to LIST. RFC 1459 §6.2.", "description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_LISTEND" "const": "323"
},
"code": {
"const": 323
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'End of /LIST'.", "description": "End of /LIST",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code", "to"
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_LISTEND",
"code": 323,
"from": "neoirc",
"to": "alice",
"body": [
"End of /LIST"
]
}
] ]
} }
+6 -22
View File
@@ -2,55 +2,39 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC", "title": "332 RPL_TOPIC",
"description": "A channel's topic. Sent to a client after it sets the topic, and after it joins a channel that has one. Also sent for each of a session's channels that has one after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.", "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_TOPIC" "const": "332"
},
"code": {
"const": 332
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: the topic.", "description": "Topic text."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_TOPIC", "command": "332",
"code": 332,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#general" "#general"
+7 -23
View File
@@ -2,62 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY", "title": "353 RPL_NAMREPLY",
"description": "A channel's members. Sent to a client after it joins a channel, and in answer to NAMES. Also sent for each of a session's channels after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.", "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_NAMREPLY" "const": "353"
},
"code": {
"const": 353
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "'=' and the channel name. The server always sends '='.", "description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)."
"minItems": 2,
"maxItems": 2
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: the members as nick!user@host, separated by spaces. '@' comes before a channel operator and '+' before a voiced member. The host is '*' when the server does not know it.", "description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_NAMREPLY", "command": "353",
"code": 353,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"=", "=",
"#general" "#general"
], ],
"body": [ "body": [
"@op1!op1@host1 alice!alice@host2 +bob!bob@host3" "@op1 alice bob +voiced1"
] ]
} }
] ]
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES", "title": "366 RPL_ENDOFNAMES",
"description": "The end of a channel's member list. It comes after the RPL_NAMREPLY for the channel. RFC 1459 §6.2.", "description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_ENDOFNAMES" "const": "366"
},
"code": {
"const": 366
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'End of /NAMES list'.", "description": "End of /NAMES list",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
],
"examples": [
{
"command": "RPL_ENDOFNAMES",
"code": 366,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"End of /NAMES list"
]
}
] ]
} }
+5 -19
View File
@@ -2,45 +2,31 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD", "title": "372 RPL_MOTD",
"description": "One line of the message of the day. The server sends one per line, after RPL_MOTDSTART. RFC 2812 §5.1.", "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_MOTD" "const": "372"
},
"code": {
"const": 372
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: '- ' and the line.", "description": "MOTD line text (prefixed with '- ')."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_MOTD", "command": "372",
"code": 372,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"- Welcome to our server!" "- Welcome to our server!"
+5 -29
View File
@@ -2,49 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART", "title": "375 RPL_MOTDSTART",
"description": "The start of the message of the day. Sent after RPL_LUSERME (255) when the server has a message of the day. RFC 2812 §5.1.", "description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_MOTDSTART" "const": "375"
},
"code": {
"const": 375
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: '- ', the server name and ' Message of the Day -'.", "description": "Text lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code", "to"
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_MOTDSTART",
"code": 375,
"from": "neoirc",
"to": "alice",
"body": [
"- neoirc Message of the Day -"
]
}
] ]
} }
+5 -28
View File
@@ -2,49 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD", "title": "376 RPL_ENDOFMOTD",
"description": "The end of the message of the day. RFC 2812 §5.1.", "description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_ENDOFMOTD" "const": "376"
},
"code": {
"const": 376
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'End of /MOTD command.'.", "description": "End of /MOTD command",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code", "to"
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_ENDOFMOTD",
"code": 376,
"from": "neoirc",
"to": "alice",
"body": [
"End of /MOTD command."
]
}
] ]
} }
+8 -24
View File
@@ -2,61 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK", "title": "401 ERR_NOSUCHNICK",
"description": "No user has that nick. RFC 1459 §6.1.", "description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NOSUCHNICK" "const": "401"
},
"code": {
"const": 401
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The nick that was not found.", "description": "[target_nick]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'No such nick' in answer to a PRIVMSG or NOTICE, and 'No such nick/channel' in answer to anything else.", "description": "No such nick/channel",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "ERR_NOSUCHNICK", "command": "401",
"code": 401,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"bob" "bob"
], ],
"body": [ "body": [
"No such nick" "No such nick/channel"
] ]
} }
] ]
+7 -23
View File
@@ -2,55 +2,39 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL", "title": "403 ERR_NOSUCHCHANNEL",
"description": "No channel has that name. A JOIN with a channel name that the server does not allow gets it too. RFC 1459 §6.1.", "description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NOSUCHCHANNEL" "const": "403"
},
"code": {
"const": 403
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel_name]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'No such channel'.", "description": "No such channel",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "ERR_NOSUCHCHANNEL", "command": "403",
"code": 403,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#nonexistent" "#nonexistent"
+9 -25
View File
@@ -2,58 +2,42 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE", "title": "433 ERR_NICKNAMEINUSE",
"description": "Another user has the nick. Sent in answer to a NICK. RFC 1459 §6.1.", "description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NICKNAMEINUSE" "const": "433"
},
"code": {
"const": 433
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick, which has not changed."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The nick that was asked for.", "description": "[requested_nick]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'Nickname is already in use'.", "description": "Nickname is already in use",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "ERR_NICKNAMEINUSE", "command": "433",
"code": 433, "to": "*",
"from": "neoirc",
"to": "alice",
"params": [ "params": [
"bob" "alice"
], ],
"body": [ "body": [
"Nickname is already in use" "Nickname is already in use"
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL", "title": "442 ERR_NOTONCHANNEL",
"description": "The user is not on that channel. RFC 1459 §6.1.", "description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NOTONCHANNEL" "const": "442"
},
"code": {
"const": 442
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'You're not on that channel'.", "description": "You're not on that channel",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
],
"examples": [
{
"command": "ERR_NOTONCHANNEL",
"code": 442,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not on that channel"
]
}
] ]
} }
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED", "title": "482 ERR_CHANOPRIVSNEEDED",
"description": "The user is not a channel operator, and the command needs one. RFC 1459 §6.1.", "description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_CHANOPRIVSNEEDED" "const": "482"
},
"code": {
"const": 482
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'You're not channel operator'.", "description": "You're not channel operator",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
],
"examples": [
{
"command": "ERR_CHANOPRIVSNEEDED",
"code": 482,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not channel operator"
]
}
] ]
} }