Compare commits
8 Commits
2853dc8a1f
...
feature/87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
327ff37059 | ||
|
|
17479c4f44 | ||
|
|
9c4ec966fb | ||
| f829f9e3da | |||
| 02279b2654 | |||
| 0250f14fea | |||
| 24362966e0 | |||
| 9a79d92c0d |
@@ -53,7 +53,7 @@ RUN apk add --no-cache ca-certificates \
|
|||||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||||
|
|
||||||
USER neoirc
|
USER neoirc
|
||||||
EXPOSE 8080
|
EXPOSE 8080 6667
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||||
ENTRYPOINT ["neoircd"]
|
ENTRYPOINT ["neoircd"]
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -1080,8 +1080,8 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
||||||
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
|
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
|
||||||
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
||||||
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","mnst"]}` |
|
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` |
|
||||||
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=,,H,mnst","NETWORK=neoirc"],"body":["are supported by this server"]}` |
|
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` |
|
||||||
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
|
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
|
||||||
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
|
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
|
||||||
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
|
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
|
||||||
@@ -1129,12 +1129,15 @@ Inspired by IRC, simplified:
|
|||||||
|
|
||||||
| Mode | Name | Meaning | Status |
|
| Mode | Name | Meaning | Status |
|
||||||
|------|----------------|---------|--------|
|
|------|----------------|---------|--------|
|
||||||
|
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
||||||
|
| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** |
|
||||||
|
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** |
|
||||||
|
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
||||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
||||||
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
|
|
||||||
| `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
|
| `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
|
||||||
|
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** |
|
||||||
|
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
|
||||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** |
|
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** |
|
||||||
| `+i` | Invite-only | Only invited users can join | Not yet enforced |
|
|
||||||
| `+s` | Secret | Channel hidden from LIST response | Not yet enforced |
|
|
||||||
|
|
||||||
**User channel modes (set per-user per-channel):**
|
**User channel modes (set per-user per-channel):**
|
||||||
|
|
||||||
@@ -1146,6 +1149,42 @@ Inspired by IRC, simplified:
|
|||||||
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
|
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
|
||||||
automatically receives `+o` operator status.
|
automatically receives `+o` operator status.
|
||||||
|
|
||||||
|
**Ban system (+b):** Operators can ban users by hostmask pattern with wildcard
|
||||||
|
matching (`*` and `?`). `MODE #channel +b` with no argument lists current bans.
|
||||||
|
Bans prevent both joining and sending messages.
|
||||||
|
|
||||||
|
```
|
||||||
|
MODE #channel +b *!*@*.example.com — ban all users from example.com
|
||||||
|
MODE #channel -b *!*@*.example.com — remove the ban
|
||||||
|
MODE #channel +b — list all bans (RPL_BANLIST 367/368)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Invite-only (+i):** When set, users must be invited by an operator before
|
||||||
|
joining. The `INVITE` command records an invite that is consumed on JOIN.
|
||||||
|
|
||||||
|
```
|
||||||
|
MODE #channel +i — set invite-only
|
||||||
|
INVITE nick #channel — invite a user (operator only on +i channels)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Channel key (+k):** Requires a password to join the channel.
|
||||||
|
|
||||||
|
```
|
||||||
|
MODE #channel +k secretpass — set a channel key
|
||||||
|
MODE #channel -k * — remove the key
|
||||||
|
JOIN #channel secretpass — join with key
|
||||||
|
```
|
||||||
|
|
||||||
|
**User limit (+l):** Caps the number of members in the channel.
|
||||||
|
|
||||||
|
```
|
||||||
|
MODE #channel +l 50 — set limit to 50 members
|
||||||
|
MODE #channel -l — remove the limit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secret (+s):** Hides the channel from `LIST` for non-members and from
|
||||||
|
`WHOIS` channel lists when the querier is not in the same channel.
|
||||||
|
|
||||||
**KICK command:** Channel operators can remove users with `KICK #channel nick
|
**KICK command:** Channel operators can remove users with `KICK #channel nick
|
||||||
[:reason]`. The kicked user and all channel members receive the KICK message.
|
[:reason]`. The kicked user and all channel members receive the KICK message.
|
||||||
|
|
||||||
@@ -1154,7 +1193,7 @@ RPL_AWAY), and skips hashcash validation on +H channels (servers and services
|
|||||||
use NOTICE).
|
use NOTICE).
|
||||||
|
|
||||||
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
|
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
|
||||||
`CHANMODES=,,H,mnst` in RPL_ISUPPORT (005).
|
`CHANMODES=b,k,Hl,imnst` in RPL_ISUPPORT (005).
|
||||||
|
|
||||||
### Per-Channel Hashcash (Anti-Spam)
|
### Per-Channel Hashcash (Anti-Spam)
|
||||||
|
|
||||||
@@ -2252,17 +2291,15 @@ neoirc includes an optional traditional IRC wire protocol listener (RFC
|
|||||||
backward compatibility with existing IRC clients like irssi, weechat, hexchat,
|
backward compatibility with existing IRC clients like irssi, weechat, hexchat,
|
||||||
and others.
|
and others.
|
||||||
|
|
||||||
### Enabling
|
### Configuration
|
||||||
|
|
||||||
Set the `IRC_LISTEN_ADDR` environment variable to a TCP address:
|
The IRC listener is **enabled by default** on `:6667`. To disable it, set
|
||||||
|
`IRC_LISTEN_ADDR` to an empty string:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
IRC_LISTEN_ADDR=:6667
|
IRC_LISTEN_ADDR=
|
||||||
```
|
```
|
||||||
|
|
||||||
When unset or empty, the IRC listener is disabled and only the HTTP/JSON API is
|
|
||||||
available.
|
|
||||||
|
|
||||||
### Supported Commands
|
### Supported Commands
|
||||||
|
|
||||||
| Category | Commands |
|
| Category | Commands |
|
||||||
@@ -2270,8 +2307,8 @@ available.
|
|||||||
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||||||
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||||||
| Messaging | `PRIVMSG`, `NOTICE` |
|
| Messaging | `PRIVMSG`, `NOTICE` |
|
||||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` |
|
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
||||||
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||||
|
|
||||||
### Protocol Details
|
### Protocol Details
|
||||||
|
|
||||||
@@ -2297,13 +2334,13 @@ connected via the HTTP API can communicate in the same channels seamlessly.
|
|||||||
|
|
||||||
### Docker Usage
|
### Docker Usage
|
||||||
|
|
||||||
To expose the IRC port in Docker:
|
To expose the IRC port in Docker (the listener is enabled by default on
|
||||||
|
`:6667`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-p 6667:6667 \
|
-p 6667:6667 \
|
||||||
-e IRC_LISTEN_ADDR=:6667 \
|
|
||||||
-v neoirc-data:/var/lib/neoirc \
|
-v neoirc-data:/var/lib/neoirc \
|
||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
@@ -2762,7 +2799,7 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
||||||
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
||||||
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
|
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
|
||||||
- [ ] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
|
- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
|
||||||
- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
|
- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
|
||||||
- [x] **KICK command** — operator-only channel kick with broadcast
|
- [x] **KICK command** — operator-only channel kick with broadcast
|
||||||
- [x] **MODE command** — query and set channel/user modes
|
- [x] **MODE command** — query and set channel/user modes
|
||||||
@@ -2783,6 +2820,10 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
login from additional devices via `POST /api/v1/login`
|
login from additional devices via `POST /api/v1/login`
|
||||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
||||||
all API authentication
|
all API authentication
|
||||||
|
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
|
||||||
|
(256–259), INFO (371/374), TIME (391), KILL (oper-only forced
|
||||||
|
disconnect), WALLOPS (oper-only broadcast to +w users)
|
||||||
|
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
|
||||||
|
|
||||||
### Future (1.0+)
|
### Future (1.0+)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package main is the entry point for the neoirc-cli client.
|
// Package main is the entry point for the neoirc-cli client.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
import "sneak.berlin/go/neoirc/internal/cli"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cli.Run()
|
cli.Run()
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
"sneak.berlin/go/neoirc/internal/server"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.eeqj.de/sneak/neoirc
|
module sneak.berlin/go/neoirc
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBroker(t *testing.T) {
|
func TestNewBroker(t *testing.T) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // .env
|
_ "github.com/joho/godotenv/autoload" // .env
|
||||||
_ "modernc.org/sqlite" // driver
|
_ "modernc.org/sqlite" // driver
|
||||||
@@ -135,13 +135,21 @@ type migration struct {
|
|||||||
func (database *Database) runMigrations(
|
func (database *Database) runMigrations(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
_, err := database.conn.ExecContext(ctx,
|
bootstrap, err := SchemaFiles.ReadFile(
|
||||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
"schema/000.sql",
|
||||||
version INTEGER PRIMARY KEY,
|
)
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"create schema_migrations: %w", err,
|
"read bootstrap migration: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = database.conn.ExecContext(
|
||||||
|
ctx, string(bootstrap),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"execute bootstrap migration: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +278,11 @@ func (database *Database) loadMigrations() (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip bootstrap migration; it is executed separately.
|
||||||
|
if version == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
content, readErr := SchemaFiles.ReadFile(
|
content, readErr := SchemaFiles.ReadFile(
|
||||||
"schema/" + entry.Name(),
|
"schema/" + entry.Name(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -1835,3 +1836,709 @@ func (database *Database) PruneSpentHashcash(
|
|||||||
|
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tier 2: Ban system (+b) ---
|
||||||
|
|
||||||
|
// BanInfo represents a channel ban entry.
|
||||||
|
type BanInfo struct {
|
||||||
|
Mask string
|
||||||
|
SetBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddChannelBan inserts a ban mask for a channel.
|
||||||
|
func (database *Database) AddChannelBan(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
mask, setBy string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`INSERT OR IGNORE INTO channel_bans
|
||||||
|
(channel_id, mask, set_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
channelID, mask, setBy, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add channel ban: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveChannelBan removes a ban mask from a channel.
|
||||||
|
func (database *Database) RemoveChannelBan(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
mask string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`DELETE FROM channel_bans
|
||||||
|
WHERE channel_id = ? AND mask = ?`,
|
||||||
|
channelID, mask)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove channel ban: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChannelBans returns all bans for a channel.
|
||||||
|
//
|
||||||
|
//nolint:dupl // different query+type vs filtered variant
|
||||||
|
func (database *Database) ListChannelBans(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) ([]BanInfo, error) {
|
||||||
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
|
`SELECT mask, set_by, created_at
|
||||||
|
FROM channel_bans
|
||||||
|
WHERE channel_id = ?
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list channel bans: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var bans []BanInfo
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var ban BanInfo
|
||||||
|
|
||||||
|
if scanErr := rows.Scan(
|
||||||
|
&ban.Mask, &ban.SetBy, &ban.CreatedAt,
|
||||||
|
); scanErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"scan channel ban: %w", scanErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
bans = append(bans, ban)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowErr := rows.Err(); rowErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"iterate channel bans: %w", rowErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bans, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSessionBanned checks if a session's hostmask matches
|
||||||
|
// any ban in the channel. Returns true if banned.
|
||||||
|
func (database *Database) IsSessionBanned(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID, sessionID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
// Get the session's hostmask parts.
|
||||||
|
var nick, username, hostname string
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT nick, username, hostname
|
||||||
|
FROM sessions WHERE id = ?`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&nick, &username, &hostname)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"get session hostmask: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostmask := FormatHostmask(nick, username, hostname)
|
||||||
|
|
||||||
|
// Get all ban masks for the channel.
|
||||||
|
bans, banErr := database.ListChannelBans(ctx, channelID)
|
||||||
|
if banErr != nil {
|
||||||
|
return false, banErr
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ban := range bans {
|
||||||
|
if MatchBanMask(ban.Mask, hostmask) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchBanMask checks if hostmask matches a ban pattern
|
||||||
|
// using IRC-style wildcard matching (* and ?).
|
||||||
|
func MatchBanMask(pattern, hostmask string) bool {
|
||||||
|
return wildcardMatch(
|
||||||
|
strings.ToLower(pattern),
|
||||||
|
strings.ToLower(hostmask),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wildcardMatch implements simple glob-style matching
|
||||||
|
// with * (any sequence) and ? (any single character).
|
||||||
|
func wildcardMatch(pattern, str string) bool {
|
||||||
|
for len(pattern) > 0 {
|
||||||
|
switch pattern[0] {
|
||||||
|
case '*':
|
||||||
|
// Skip consecutive asterisks.
|
||||||
|
for len(pattern) > 0 && pattern[0] == '*' {
|
||||||
|
pattern = pattern[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pattern) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i <= len(str); i++ {
|
||||||
|
if wildcardMatch(pattern, str[i:]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
case '?':
|
||||||
|
if len(str) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = pattern[1:]
|
||||||
|
str = str[1:]
|
||||||
|
default:
|
||||||
|
if len(str) == 0 || pattern[0] != str[0] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = pattern[1:]
|
||||||
|
str = str[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(str) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 2: Invite-only (+i) ---
|
||||||
|
|
||||||
|
// IsChannelInviteOnly checks if a channel has +i mode.
|
||||||
|
func (database *Database) IsChannelInviteOnly(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isInviteOnly int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT is_invite_only FROM channels
|
||||||
|
WHERE id = ?`,
|
||||||
|
channelID,
|
||||||
|
).Scan(&isInviteOnly)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check invite only: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isInviteOnly != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelInviteOnly sets or unsets +i mode.
|
||||||
|
func (database *Database) SetChannelInviteOnly(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
inviteOnly bool,
|
||||||
|
) error {
|
||||||
|
val := 0
|
||||||
|
if inviteOnly {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET is_invite_only = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
val, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"set invite only: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddChannelInvite records that a session has been
|
||||||
|
// invited to a channel.
|
||||||
|
func (database *Database) AddChannelInvite(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID, sessionID int64,
|
||||||
|
invitedBy string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`INSERT OR IGNORE INTO channel_invites
|
||||||
|
(channel_id, session_id, invited_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
channelID, sessionID, invitedBy, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add channel invite: %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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearChannelInvite removes a session's invite to a
|
||||||
|
// channel (called after successful JOIN).
|
||||||
|
func (database *Database) ClearChannelInvite(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID, sessionID int64,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`DELETE FROM channel_invites
|
||||||
|
WHERE channel_id = ? AND session_id = ?`,
|
||||||
|
channelID, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear invite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 2: Secret (+s) ---
|
||||||
|
|
||||||
|
// IsChannelSecret checks if a channel has +s mode.
|
||||||
|
func (database *Database) IsChannelSecret(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isSecret int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT is_secret FROM channels
|
||||||
|
WHERE id = ?`,
|
||||||
|
channelID,
|
||||||
|
).Scan(&isSecret)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check secret: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSecret != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelSecret sets or unsets +s mode.
|
||||||
|
func (database *Database) SetChannelSecret(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
secret bool,
|
||||||
|
) error {
|
||||||
|
val := 0
|
||||||
|
if secret {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET is_secret = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
val, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- No External Messages (+n) ---
|
||||||
|
|
||||||
|
// IsChannelNoExternal checks if a channel has +n mode.
|
||||||
|
func (database *Database) IsChannelNoExternal(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isNoExternal int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT is_no_external FROM channels
|
||||||
|
WHERE id = ?`,
|
||||||
|
channelID,
|
||||||
|
).Scan(&isNoExternal)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check no external: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNoExternal != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelNoExternal sets or unsets +n mode.
|
||||||
|
func (database *Database) SetChannelNoExternal(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
noExternal bool,
|
||||||
|
) error {
|
||||||
|
val := 0
|
||||||
|
if noExternal {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET is_no_external = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
val, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set no external: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllChannelsWithCountsFiltered returns all channels
|
||||||
|
// with member counts, excluding secret channels that
|
||||||
|
// the given session is not a member of.
|
||||||
|
//
|
||||||
|
//nolint:dupl // different query+type vs ListChannelBans
|
||||||
|
func (database *Database) ListAllChannelsWithCountsFiltered(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) ([]ChannelInfoFull, error) {
|
||||||
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
|
`SELECT c.name, COUNT(cm.id) AS member_count,
|
||||||
|
c.topic
|
||||||
|
FROM channels c
|
||||||
|
LEFT JOIN channel_members cm
|
||||||
|
ON cm.channel_id = c.id
|
||||||
|
WHERE c.is_secret = 0
|
||||||
|
OR c.id IN (
|
||||||
|
SELECT channel_id FROM channel_members
|
||||||
|
WHERE session_id = ?
|
||||||
|
)
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.name ASC`,
|
||||||
|
sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"list channels filtered: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var channels []ChannelInfoFull
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var chanInfo ChannelInfoFull
|
||||||
|
|
||||||
|
if scanErr := rows.Scan(
|
||||||
|
&chanInfo.Name,
|
||||||
|
&chanInfo.MemberCount,
|
||||||
|
&chanInfo.Topic,
|
||||||
|
); scanErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"scan channel: %w", scanErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = append(channels, chanInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowErr := rows.Err(); rowErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"iterate channels: %w", rowErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionChannelsFiltered returns channels a session
|
||||||
|
// belongs to, optionally excluding secret channels for
|
||||||
|
// WHOIS (when the querier is not in the same channel).
|
||||||
|
// If querierID == targetID, returns all channels.
|
||||||
|
func (database *Database) GetSessionChannelsFiltered(
|
||||||
|
ctx context.Context,
|
||||||
|
targetSID, querierSID int64,
|
||||||
|
) ([]ChannelInfo, error) {
|
||||||
|
// If querying yourself, return all channels.
|
||||||
|
if targetSID == querierSID {
|
||||||
|
return database.GetSessionChannels(ctx, targetSID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
|
`SELECT c.id, c.name, c.topic
|
||||||
|
FROM channels c
|
||||||
|
JOIN channel_members cm
|
||||||
|
ON cm.channel_id = c.id
|
||||||
|
WHERE cm.session_id = ?
|
||||||
|
AND (c.is_secret = 0
|
||||||
|
OR c.id IN (
|
||||||
|
SELECT channel_id FROM channel_members
|
||||||
|
WHERE session_id = ?
|
||||||
|
))
|
||||||
|
ORDER BY c.name ASC`,
|
||||||
|
targetSID, querierSID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"get session channels filtered: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var channels []ChannelInfo
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var chanInfo ChannelInfo
|
||||||
|
|
||||||
|
if scanErr := rows.Scan(
|
||||||
|
&chanInfo.ID,
|
||||||
|
&chanInfo.Name,
|
||||||
|
&chanInfo.Topic,
|
||||||
|
); scanErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"scan channel: %w", scanErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = append(channels, chanInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowErr := rows.Err(); rowErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"iterate channels: %w", rowErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 2: Channel Key (+k) ---
|
||||||
|
|
||||||
|
// GetChannelKey returns the key for a channel (empty
|
||||||
|
// string means no key set).
|
||||||
|
func (database *Database) GetChannelKey(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (string, error) {
|
||||||
|
var key string
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT channel_key FROM channels
|
||||||
|
WHERE id = ?`,
|
||||||
|
channelID,
|
||||||
|
).Scan(&key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get channel key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelKey sets or clears the key for a channel.
|
||||||
|
func (database *Database) SetChannelKey(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
key string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET channel_key = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
key, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set channel key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 2: User Limit (+l) ---
|
||||||
|
|
||||||
|
// GetChannelUserLimit returns the user limit for a
|
||||||
|
// channel (0 means no limit).
|
||||||
|
func (database *Database) GetChannelUserLimit(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (int, error) {
|
||||||
|
var limit int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT user_limit FROM channels
|
||||||
|
WHERE id = ?`,
|
||||||
|
channelID,
|
||||||
|
).Scan(&limit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get channel user limit: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelUserLimit sets the user limit for a channel.
|
||||||
|
func (database *Database) SetChannelUserLimit(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
limit int,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET user_limit = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
limit, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"set channel user limit: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionWallops sets the wallops (+w) flag on a
|
||||||
|
// session.
|
||||||
|
func (database *Database) SetSessionWallops(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
enabled bool,
|
||||||
|
) error {
|
||||||
|
val := 0
|
||||||
|
if enabled {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.conn.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||||
|
val, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set session wallops: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSessionWallops returns whether the session has the
|
||||||
|
// wallops (+w) usermode set.
|
||||||
|
func (database *Database) IsSessionWallops(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isWallops int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&isWallops)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check session wallops: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isWallops != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWallopsSessionIDs returns all session IDs that have
|
||||||
|
// the wallops (+w) usermode set.
|
||||||
|
func (database *Database) GetWallopsSessionIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
) ([]int64, error) {
|
||||||
|
rows, err := database.conn.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"get wallops sessions: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var sessionID int64
|
||||||
|
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"scan wallops session: %w", scanErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"iterate wallops sessions: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserhostInfo holds the data needed for RPL_USERHOST.
|
||||||
|
type UserhostInfo struct {
|
||||||
|
Nick string
|
||||||
|
Username string
|
||||||
|
Hostname string
|
||||||
|
IsOper bool
|
||||||
|
AwayMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserhostInfo returns USERHOST info for the given
|
||||||
|
// nicks. Only nicks that exist are returned.
|
||||||
|
func (database *Database) GetUserhostInfo(
|
||||||
|
ctx context.Context,
|
||||||
|
nicks []string,
|
||||||
|
) ([]UserhostInfo, error) {
|
||||||
|
if len(nicks) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]UserhostInfo, 0, len(nicks))
|
||||||
|
|
||||||
|
for _, nick := range nicks {
|
||||||
|
var info UserhostInfo
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT nick, username, hostname,
|
||||||
|
is_oper, away_message
|
||||||
|
FROM sessions WHERE nick = ?`,
|
||||||
|
nick,
|
||||||
|
).Scan(
|
||||||
|
&info.Nick, &info.Username, &info.Hostname,
|
||||||
|
&info.IsOper, &info.AwayMessage,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue // nick not found, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -1017,3 +1017,474 @@ func TestGetOperCount(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 oper, got %d", count)
|
t.Fatalf("expected 1 oper, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tier 2 Tests ---
|
||||||
|
|
||||||
|
func TestWildcardMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
pattern string
|
||||||
|
input string
|
||||||
|
match bool
|
||||||
|
}{
|
||||||
|
{"*!*@*", "nick!user@host", true},
|
||||||
|
{"*!*@*.example.com", "nick!user@foo.example.com", true},
|
||||||
|
{"*!*@*.example.com", "nick!user@other.net", false},
|
||||||
|
{"badnick!*@*", "badnick!user@host", true},
|
||||||
|
{"badnick!*@*", "goodnick!user@host", false},
|
||||||
|
{"nick!user@host", "nick!user@host", true},
|
||||||
|
{"nick!user@host", "nick!user@other", false},
|
||||||
|
{"*", "anything", true},
|
||||||
|
{"?ick!*@*", "nick!user@host", true},
|
||||||
|
{"?ick!*@*", "nn!user@host", false},
|
||||||
|
// Case-insensitive.
|
||||||
|
{"Nick!*@*", "nick!user@host", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := db.MatchBanMask(tc.pattern, tc.input)
|
||||||
|
if result != tc.match {
|
||||||
|
t.Errorf(
|
||||||
|
"MatchBanMask(%q, %q) = %v, want %v",
|
||||||
|
tc.pattern, tc.input, result, tc.match,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelBanCRUD(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bans initially.
|
||||||
|
bans, err := database.ListChannelBans(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bans) != 0 {
|
||||||
|
t.Fatalf("expected 0 bans, got %d", len(bans))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a ban.
|
||||||
|
err = database.AddChannelBan(
|
||||||
|
ctx, chID, "*!*@evil.com", "op",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bans, err = database.ListChannelBans(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bans) != 1 {
|
||||||
|
t.Fatalf("expected 1 ban, got %d", len(bans))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bans[0].Mask != "*!*@evil.com" {
|
||||||
|
t.Fatalf("wrong mask: %s", bans[0].Mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate add is ignored (OR IGNORE).
|
||||||
|
err = database.AddChannelBan(
|
||||||
|
ctx, chID, "*!*@evil.com", "op2",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bans, _ = database.ListChannelBans(ctx, chID)
|
||||||
|
if len(bans) != 1 {
|
||||||
|
t.Fatalf("expected 1 ban after dup, got %d", len(bans))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ban.
|
||||||
|
err = database.RemoveChannelBan(
|
||||||
|
ctx, chID, "*!*@evil.com",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bans, _ = database.ListChannelBans(ctx, chID)
|
||||||
|
if len(bans) != 0 {
|
||||||
|
t.Fatalf("expected 0 bans after remove, got %d", len(bans))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSessionBanned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid, _, _, err := database.CreateSession(
|
||||||
|
ctx, "victim", "victim", "evil.com", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#bantest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not banned initially.
|
||||||
|
banned, err := database.IsSessionBanned(ctx, chID, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if banned {
|
||||||
|
t.Fatal("should not be banned initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ban matching the hostmask.
|
||||||
|
err = database.AddChannelBan(
|
||||||
|
ctx, chID, "*!*@evil.com", "op",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
banned, err = database.IsSessionBanned(ctx, chID, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !banned {
|
||||||
|
t.Fatal("should be banned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelInviteOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#invite")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: not invite-only.
|
||||||
|
isIO, err := database.IsChannelInviteOnly(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isIO {
|
||||||
|
t.Fatal("should not be invite-only by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set invite-only.
|
||||||
|
err = database.SetChannelInviteOnly(ctx, chID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
|
||||||
|
if !isIO {
|
||||||
|
t.Fatal("should be invite-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset.
|
||||||
|
err = database.SetChannelInviteOnly(ctx, chID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
|
||||||
|
if isIO {
|
||||||
|
t.Fatal("should not be invite-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelInviteCRUD(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid, _, _, err := database.CreateSession(
|
||||||
|
ctx, "invited", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#inv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No invite initially.
|
||||||
|
has, err := database.HasChannelInvite(ctx, chID, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if has {
|
||||||
|
t.Fatal("should not have invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add invite.
|
||||||
|
err = database.AddChannelInvite(ctx, chID, sid, "op")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
||||||
|
if !has {
|
||||||
|
t.Fatal("should have invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear invite.
|
||||||
|
err = database.ClearChannelInvite(ctx, chID, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
||||||
|
if has {
|
||||||
|
t.Fatal("invite should be cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: not secret.
|
||||||
|
isSec, err := database.IsChannelSecret(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSec {
|
||||||
|
t.Fatal("should not be secret by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = database.SetChannelSecret(ctx, chID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSec, _ = database.IsChannelSecret(ctx, chID)
|
||||||
|
if !isSec {
|
||||||
|
t.Fatal("should be secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestSession is a helper to create a session and
|
||||||
|
// return only the session ID.
|
||||||
|
func createTestSession(
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
nick string,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sid, _, _, err := database.CreateSession(
|
||||||
|
t.Context(), nick, "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session %s: %v", nick, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretChannelFiltering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Create two sessions.
|
||||||
|
sid1 := createTestSession(t, database, "member")
|
||||||
|
sid2 := createTestSession(t, database, "outsider")
|
||||||
|
|
||||||
|
// Create a secret channel.
|
||||||
|
chID, _ := database.GetOrCreateChannel(ctx, "#secret")
|
||||||
|
_ = database.SetChannelSecret(ctx, chID, true)
|
||||||
|
_ = database.JoinChannel(ctx, chID, sid1)
|
||||||
|
|
||||||
|
// Create a non-secret channel.
|
||||||
|
chID2, _ := database.GetOrCreateChannel(ctx, "#public")
|
||||||
|
_ = database.JoinChannel(ctx, chID2, sid1)
|
||||||
|
|
||||||
|
// Member should see both.
|
||||||
|
list, err := database.ListAllChannelsWithCountsFiltered(
|
||||||
|
ctx, sid1,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("member should see 2 channels, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outsider should only see public.
|
||||||
|
list, _ = database.ListAllChannelsWithCountsFiltered(
|
||||||
|
ctx, sid2,
|
||||||
|
)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("outsider should see 1 channel, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
if list[0].Name != "#public" {
|
||||||
|
t.Fatalf("outsider should see #public, got %s", list[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoisChannelFiltering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createTestSession(t, database, "target")
|
||||||
|
sid2 := createTestSession(t, database, "querier")
|
||||||
|
|
||||||
|
// Create secret channel, target joins it.
|
||||||
|
chID, _ := database.GetOrCreateChannel(ctx, "#hidden")
|
||||||
|
_ = database.SetChannelSecret(ctx, chID, true)
|
||||||
|
_ = database.JoinChannel(ctx, chID, sid1)
|
||||||
|
|
||||||
|
// Querier (non-member) should not see the channel.
|
||||||
|
channels, err := database.GetSessionChannelsFiltered(
|
||||||
|
ctx, sid1, sid2,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(channels) != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"querier should see 0 channels, got %d",
|
||||||
|
len(channels),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target querying self should see it.
|
||||||
|
channels, _ = database.GetSessionChannelsFiltered(
|
||||||
|
ctx, sid1, sid1,
|
||||||
|
)
|
||||||
|
if len(channels) != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"self-query should see 1 channel, got %d",
|
||||||
|
len(channels),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:dupl // structurally similar to TestChannelUserLimit
|
||||||
|
func TestChannelKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#keyed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no key.
|
||||||
|
key, err := database.GetChannelKey(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
t.Fatalf("expected empty key, got %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set key.
|
||||||
|
err = database.SetChannelKey(ctx, chID, "secret123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, _ = database.GetChannelKey(ctx, chID)
|
||||||
|
if key != "secret123" {
|
||||||
|
t.Fatalf("expected secret123, got %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear key.
|
||||||
|
err = database.SetChannelKey(ctx, chID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, _ = database.GetChannelKey(ctx, chID)
|
||||||
|
if key != "" {
|
||||||
|
t.Fatalf("expected empty key, got %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:dupl // structurally similar to TestChannelKey
|
||||||
|
func TestChannelUserLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
chID, err := database.GetOrCreateChannel(ctx, "#limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no limit.
|
||||||
|
limit, err := database.GetChannelUserLimit(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit != 0 {
|
||||||
|
t.Fatalf("expected 0 limit, got %d", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set limit.
|
||||||
|
err = database.SetChannelUserLimit(ctx, chID, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ = database.GetChannelUserLimit(ctx, chID)
|
||||||
|
if limit != 50 {
|
||||||
|
t.Fatalf("expected 50, got %d", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear limit.
|
||||||
|
err = database.SetChannelUserLimit(ctx, chID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ = database.GetChannelUserLimit(ctx, chID)
|
||||||
|
if limit != 0 {
|
||||||
|
t.Fatalf("expected 0, got %d", limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
6
internal/db/schema/000.sql
Normal file
6
internal/db/schema/000.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Bootstrap: create the schema_migrations table itself.
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
hostname TEXT NOT NULL DEFAULT '',
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_wallops INTEGER NOT NULL DEFAULT 0,
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
@@ -42,10 +43,37 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||||
is_moderated INTEGER NOT NULL DEFAULT 0,
|
is_moderated INTEGER NOT NULL DEFAULT 0,
|
||||||
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_no_external INTEGER NOT NULL DEFAULT 1,
|
||||||
|
channel_key TEXT NOT NULL DEFAULT '',
|
||||||
|
user_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Channel bans
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_bans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
mask TEXT NOT NULL,
|
||||||
|
set_by TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(channel_id, mask)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_bans_channel ON channel_bans(channel_id);
|
||||||
|
|
||||||
|
-- Channel invites (in-memory would be simpler but DB survives restarts)
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_invites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
invited_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(channel_id, session_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_invites_channel ON channel_invites(channel_id);
|
||||||
|
|
||||||
-- Channel members
|
-- Channel members
|
||||||
CREATE TABLE IF NOT EXISTS channel_members (
|
CREATE TABLE IF NOT EXISTS channel_members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,20 +18,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
"sneak.berlin/go/neoirc/internal/server"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -207,6 +208,14 @@ func newTestHandlers(
|
|||||||
hcheck *healthcheck.Healthcheck,
|
hcheck *healthcheck.Healthcheck,
|
||||||
tracker *stats.Tracker,
|
tracker *stats.Tracker,
|
||||||
) (*handlers.Handlers, error) {
|
) (*handlers.Handlers, error) {
|
||||||
|
brk := broker.New()
|
||||||
|
svc := service.New(service.Params{ //nolint:exhaustruct
|
||||||
|
Logger: log,
|
||||||
|
Config: cfg,
|
||||||
|
Database: database,
|
||||||
|
Broker: brk,
|
||||||
|
})
|
||||||
|
|
||||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||||
Logger: log,
|
Logger: log,
|
||||||
Globals: globs,
|
Globals: globs,
|
||||||
@@ -214,7 +223,8 @@ func newTestHandlers(
|
|||||||
Database: database,
|
Database: database,
|
||||||
Healthcheck: hcheck,
|
Healthcheck: hcheck,
|
||||||
Stats: tracker,
|
Stats: tracker,
|
||||||
Broker: broker.New(),
|
Broker: brk,
|
||||||
|
Service: svc,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test handlers: %w", err)
|
return nil, fmt.Errorf("test handlers: %w", err)
|
||||||
@@ -4380,3 +4390,486 @@ func TestKickDefaultReason(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tier 2 Handler Tests ---
|
||||||
|
|
||||||
|
const (
|
||||||
|
inviteCmd = "INVITE"
|
||||||
|
joinedStatus = "joined"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBanAddRemoveList verifies +b add, list, and -b
|
||||||
|
// remove via MODE commands.
|
||||||
|
func TestBanAddRemoveList(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("banop")
|
||||||
|
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#bans",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a ban.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#bans",
|
||||||
|
bodyKey: []string{"+b", "*!*@evil.com"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(opToken, 0)
|
||||||
|
|
||||||
|
// List bans (+b with no argument).
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#bans",
|
||||||
|
bodyKey: []string{"+b"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
||||||
|
|
||||||
|
// Should have RPL_BANLIST (367).
|
||||||
|
banMsg := findNumericWithParams(msgs, "367")
|
||||||
|
if banMsg == nil {
|
||||||
|
t.Fatalf("expected 367 RPL_BANLIST, got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have RPL_ENDOFBANLIST (368).
|
||||||
|
if !findNumeric(msgs, "368") {
|
||||||
|
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the ban.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#bans",
|
||||||
|
bodyKey: []string{"-b", "*!*@evil.com"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(opToken, lastID)
|
||||||
|
|
||||||
|
// List again — should be empty (just end-of-list).
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#bans",
|
||||||
|
bodyKey: []string{"+b"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(opToken, lastID)
|
||||||
|
banMsg = findNumericWithParams(msgs, "367")
|
||||||
|
if banMsg != nil {
|
||||||
|
t.Fatal("expected no 367 after ban removal")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "368") {
|
||||||
|
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBanBlocksJoin verifies that a banned user cannot
|
||||||
|
// join a channel.
|
||||||
|
func TestBanBlocksJoin(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("banop2")
|
||||||
|
userToken := tserver.createSession("banned2")
|
||||||
|
|
||||||
|
// Op creates channel and sets a ban.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#banjoin",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#banjoin",
|
||||||
|
bodyKey: []string{"+b", "banned2!*@*"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Banned user tries to join.
|
||||||
|
_, lastID := tserver.pollMessages(userToken, 0)
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#banjoin",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||||
|
|
||||||
|
// Should get ERR_BANNEDFROMCHAN (474).
|
||||||
|
if !findNumeric(msgs, "474") {
|
||||||
|
t.Fatalf("expected 474 ERR_BANNEDFROMCHAN, got %v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBanBlocksPrivmsg verifies that a banned user who
|
||||||
|
// is already in a channel cannot send messages.
|
||||||
|
func TestBanBlocksPrivmsg(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("banmsgop")
|
||||||
|
userToken := tserver.createSession("banmsgusr")
|
||||||
|
|
||||||
|
// Both join.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#banmsg",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#banmsg",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Op bans the user.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#banmsg",
|
||||||
|
bodyKey: []string{"+b", "banmsgusr!*@*"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// User tries to send a message.
|
||||||
|
_, lastID := tserver.pollMessages(userToken, 0)
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#banmsg",
|
||||||
|
bodyKey: []string{"hello"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||||
|
|
||||||
|
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||||
|
if !findNumeric(msgs, "404") {
|
||||||
|
t.Fatalf("expected 404 ERR_CANNOTSENDTOCHAN, got %v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInviteOnlyJoin verifies +i behavior: join rejected
|
||||||
|
// without invite, accepted with invite.
|
||||||
|
func TestInviteOnlyJoin(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("invop")
|
||||||
|
userToken := tserver.createSession("invusr")
|
||||||
|
|
||||||
|
// Op creates channel and sets +i.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#invonly",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#invonly",
|
||||||
|
bodyKey: []string{"+i"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// User tries to join without invite.
|
||||||
|
_, lastID := tserver.pollMessages(userToken, 0)
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#invonly",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "473") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 473 ERR_INVITEONLYCHAN, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Op invites user.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: inviteCmd,
|
||||||
|
bodyKey: []string{"invusr", "#invonly"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// User tries again — should succeed with invite.
|
||||||
|
_, result := tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#invonly",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result[statusKey] != joinedStatus {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected join to succeed with invite, got %v",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretChannelHiddenFromList verifies +s hides a
|
||||||
|
// channel from LIST for non-members.
|
||||||
|
func TestSecretChannelHiddenFromList(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("secop")
|
||||||
|
outsiderToken := tserver.createSession("secout")
|
||||||
|
|
||||||
|
// Op creates secret channel.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#secret",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#secret",
|
||||||
|
bodyKey: []string{"+s"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Outsider does LIST.
|
||||||
|
_, lastID := tserver.pollMessages(outsiderToken, 0)
|
||||||
|
tserver.sendCommand(outsiderToken, map[string]any{
|
||||||
|
commandKey: "LIST",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(outsiderToken, lastID)
|
||||||
|
|
||||||
|
// Should NOT see #secret in any 322 (RPL_LIST).
|
||||||
|
for _, msg := range msgs {
|
||||||
|
code, ok := msg["code"].(float64)
|
||||||
|
if !ok || int(code) != 322 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
params := getNumericParams(msg)
|
||||||
|
for _, p := range params {
|
||||||
|
if p == "#secret" {
|
||||||
|
t.Fatal("outsider should not see #secret in LIST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member does LIST — should see it.
|
||||||
|
_, lastID = tserver.pollMessages(opToken, 0)
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: "LIST",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(opToken, lastID)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
code, ok := msg["code"].(float64)
|
||||||
|
if !ok || int(code) != 322 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
params := getNumericParams(msg)
|
||||||
|
for _, p := range params {
|
||||||
|
if p == "#secret" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Fatal("member should see #secret in LIST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChannelKeyJoin verifies +k behavior: wrong/missing
|
||||||
|
// key is rejected, correct key allows join.
|
||||||
|
func TestChannelKeyJoin(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("keyop")
|
||||||
|
userToken := tserver.createSession("keyusr")
|
||||||
|
|
||||||
|
// Op creates keyed channel.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#keyed",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#keyed",
|
||||||
|
bodyKey: []string{"+k", "mykey"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// User tries without key.
|
||||||
|
_, lastID := tserver.pollMessages(userToken, 0)
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#keyed",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "475") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 475 ERR_BADCHANNELKEY, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User tries with wrong key.
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd,
|
||||||
|
toKey: "#keyed",
|
||||||
|
bodyKey: []string{"wrongkey"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(userToken, lastID)
|
||||||
|
if !findNumeric(msgs, "475") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 475 ERR_BADCHANNELKEY for wrong key, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User tries with correct key.
|
||||||
|
_, result := tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd,
|
||||||
|
toKey: "#keyed",
|
||||||
|
bodyKey: []string{"mykey"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result[statusKey] != joinedStatus {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected join to succeed with correct key, got %v",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUserLimitEnforcement verifies +l behavior: blocks
|
||||||
|
// join when at capacity.
|
||||||
|
func TestUserLimitEnforcement(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("limop")
|
||||||
|
user1Token := tserver.createSession("limusr1")
|
||||||
|
user2Token := tserver.createSession("limusr2")
|
||||||
|
|
||||||
|
// Op creates channel with limit 2.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#limited",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#limited",
|
||||||
|
bodyKey: []string{"+l", "2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// User1 joins — should succeed (2 members now: op + user1).
|
||||||
|
_, result := tserver.sendCommand(user1Token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#limited",
|
||||||
|
})
|
||||||
|
if result[statusKey] != joinedStatus {
|
||||||
|
t.Fatalf("user1 should join, got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User2 tries to join — should fail (at limit: 2/2).
|
||||||
|
_, lastID := tserver.pollMessages(user2Token, 0)
|
||||||
|
tserver.sendCommand(user2Token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#limited",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(user2Token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "471") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 471 ERR_CHANNELISFULL, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModeStringIncludesNewModes verifies that querying
|
||||||
|
// channel mode returns the new modes (+i, +s, +k, +l).
|
||||||
|
func TestModeStringIncludesNewModes(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("modestrop")
|
||||||
|
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#modestr",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set all tier 2 modes.
|
||||||
|
for _, modeChange := range [][]string{
|
||||||
|
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
|
||||||
|
} {
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#modestr",
|
||||||
|
bodyKey: modeChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(opToken, 0)
|
||||||
|
|
||||||
|
// Query mode.
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: modeCmd, toKey: "#modestr",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
||||||
|
modeMsg := findNumericWithParams(msgs, "324")
|
||||||
|
|
||||||
|
if modeMsg == nil {
|
||||||
|
t.Fatal("expected 324 RPL_CHANNELMODEIS")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := getNumericParams(modeMsg)
|
||||||
|
if len(params) < 2 {
|
||||||
|
t.Fatalf("too few params in 324: %v", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
modeString := params[1]
|
||||||
|
|
||||||
|
for _, c := range []string{"i", "s", "k", "l"} {
|
||||||
|
if !strings.Contains(modeString, c) {
|
||||||
|
t.Fatalf(
|
||||||
|
"mode string %q missing %q",
|
||||||
|
modeString, c,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestISUPPORT verifies the 005 numeric includes the
|
||||||
|
// updated CHANMODES string.
|
||||||
|
func TestISUPPORT(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("isupport")
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
isupp := findNumericWithParams(msgs, "005")
|
||||||
|
if isupp == nil {
|
||||||
|
t.Fatal("expected 005 RPL_ISUPPORT")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := isupp["body"].(string)
|
||||||
|
params := getNumericParams(isupp)
|
||||||
|
|
||||||
|
combined := body + " " + strings.Join(params, " ")
|
||||||
|
|
||||||
|
if !strings.Contains(combined, "CHANMODES=b,k,Hl,imnst") {
|
||||||
|
t.Fatalf(
|
||||||
|
"ISUPPORT missing updated CHANMODES, got body=%q params=%v",
|
||||||
|
body, params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNonOpCannotSetModes verifies non-operators
|
||||||
|
// cannot set +i, +s, +k, +l, +b.
|
||||||
|
func TestNonOpCannotSetModes(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
opToken := tserver.createSession("modeopx")
|
||||||
|
userToken := tserver.createSession("modeusrx")
|
||||||
|
|
||||||
|
tserver.sendCommand(opToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#noperm",
|
||||||
|
})
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#noperm",
|
||||||
|
})
|
||||||
|
|
||||||
|
modes := [][]string{
|
||||||
|
{"+i"}, {"+s"}, {"+k", "key"}, {"+l", "10"},
|
||||||
|
{"+b", "bad!*@*"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, modeChange := range modes {
|
||||||
|
_, lastID := tserver.pollMessages(userToken, 0)
|
||||||
|
tserver.sendCommand(userToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#noperm",
|
||||||
|
bodyKey: modeChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||||
|
|
||||||
|
// Should get 482 ERR_CHANOPRIVSNEEDED.
|
||||||
|
if !findNumeric(msgs, "482") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 482 for %v, got %v",
|
||||||
|
modeChange, msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errUnauthorized = errors.New("unauthorized")
|
var errUnauthorized = errors.New("unauthorized")
|
||||||
|
|||||||
659
internal/handlers/utility.go
Normal file
659
internal/handlers/utility.go
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxUserhostNicks is the maximum number of nicks allowed
|
||||||
|
// in a single USERHOST query (RFC 2812).
|
||||||
|
const maxUserhostNicks = 5
|
||||||
|
|
||||||
|
// dispatchBodyOnlyCommand routes commands that take
|
||||||
|
// (writer, request, sessionID, clientID, nick, bodyLines).
|
||||||
|
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, command string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
switch command {
|
||||||
|
case irc.CmdAway:
|
||||||
|
hdlr.handleAway(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdNick:
|
||||||
|
hdlr.handleNick(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdPass:
|
||||||
|
hdlr.handlePass(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdInvite:
|
||||||
|
hdlr.handleInvite(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchOperCommand routes oper-related commands (OPER,
|
||||||
|
// KILL, WALLOPS) to their handlers.
|
||||||
|
func (hdlr *Handlers) dispatchOperCommand(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, command string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
switch command {
|
||||||
|
case irc.CmdOper:
|
||||||
|
hdlr.handleOper(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdKill:
|
||||||
|
hdlr.handleKill(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdWallops:
|
||||||
|
hdlr.handleWallops(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserhost handles the USERHOST command.
|
||||||
|
// Returns user@host info for up to 5 nicks.
|
||||||
|
func (hdlr *Handlers) handleUserhost(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdUserhost},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 5 nicks per RFC 2812.
|
||||||
|
nicks := lines
|
||||||
|
if len(nicks) > maxUserhostNicks {
|
||||||
|
nicks = nicks[:maxUserhostNicks]
|
||||||
|
}
|
||||||
|
|
||||||
|
infos, err := hdlr.params.Database.GetUserhostInfo(
|
||||||
|
ctx, nicks,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"userhost query failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
replyStr := hdlr.buildUserhostReply(infos)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUserHost, nick, nil,
|
||||||
|
replyStr,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserhostReply builds the RPL_USERHOST reply
|
||||||
|
// string per RFC 2812.
|
||||||
|
func (hdlr *Handlers) buildUserhostReply(
|
||||||
|
infos []db.UserhostInfo,
|
||||||
|
) string {
|
||||||
|
replies := make([]string, 0, len(infos))
|
||||||
|
|
||||||
|
for idx := range infos {
|
||||||
|
info := &infos[idx]
|
||||||
|
|
||||||
|
username := info.Username
|
||||||
|
if username == "" {
|
||||||
|
username = info.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := info.Hostname
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = hdlr.serverName()
|
||||||
|
}
|
||||||
|
|
||||||
|
operStar := ""
|
||||||
|
if info.IsOper {
|
||||||
|
operStar = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
awayPrefix := "+"
|
||||||
|
if info.AwayMessage != "" {
|
||||||
|
awayPrefix = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
replies = append(replies,
|
||||||
|
info.Nick+operStar+"="+
|
||||||
|
awayPrefix+username+"@"+hostname,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(replies, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVersion handles the VERSION command.
|
||||||
|
// Returns the server version string.
|
||||||
|
func (hdlr *Handlers) handleVersion(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
version := hdlr.serverVersion()
|
||||||
|
|
||||||
|
// 351 RPL_VERSION
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplVersion, nick,
|
||||||
|
[]string{version + ".", srvName},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdmin handles the ADMIN command.
|
||||||
|
// Returns server admin contact info.
|
||||||
|
func (hdlr *Handlers) handleAdmin(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
// 256 RPL_ADMINME
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminMe, nick,
|
||||||
|
[]string{srvName},
|
||||||
|
"Administrative info",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 257 RPL_ADMINLOC1
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
||||||
|
"neoirc server",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 258 RPL_ADMINLOC2
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
||||||
|
"IRC over HTTP",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 259 RPL_ADMINEMAIL
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
||||||
|
"admin@"+srvName,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo handles the INFO command.
|
||||||
|
// Returns server software information.
|
||||||
|
func (hdlr *Handlers) handleInfo(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
version := hdlr.serverVersion()
|
||||||
|
|
||||||
|
infoLines := []string{
|
||||||
|
"neoirc — IRC semantics over HTTP",
|
||||||
|
"Version: " + version,
|
||||||
|
"Written in Go",
|
||||||
|
"Started: " +
|
||||||
|
hdlr.params.Globals.StartTime.
|
||||||
|
Format(time.RFC1123),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range infoLines {
|
||||||
|
// 371 RPL_INFO
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplInfo, nick, nil,
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 374 RPL_ENDOFINFO
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
||||||
|
"End of /INFO list",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime handles the TIME command.
|
||||||
|
// Returns the server's local time in RFC format.
|
||||||
|
func (hdlr *Handlers) handleTime(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
// 391 RPL_TIME
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplTime, nick,
|
||||||
|
[]string{srvName},
|
||||||
|
time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKill handles the KILL command.
|
||||||
|
// Forcibly disconnects a user (oper only).
|
||||||
|
func (hdlr *Handlers) handleKill(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
// Check oper status.
|
||||||
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNoPrivileges, nick, nil,
|
||||||
|
"Permission Denied- You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
|
||||||
|
var targetNick string
|
||||||
|
if len(lines) > 0 {
|
||||||
|
targetNick = strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetNick == "" {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdKill},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "KILLed"
|
||||||
|
if len(lines) > 1 {
|
||||||
|
reason = lines[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, lookupErr := hdlr.params.Database.
|
||||||
|
GetSessionByNick(ctx, targetNick)
|
||||||
|
if lookupErr != nil {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNoSuchNick, nick,
|
||||||
|
[]string{targetNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow killing yourself.
|
||||||
|
if targetSID == sessionID {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrCantKillServer, nick, nil,
|
||||||
|
"You cannot KILL yourself",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||||
|
|
||||||
|
hdlr.svc.BroadcastQuit(
|
||||||
|
request.Context(), targetSID,
|
||||||
|
targetNick, quitReason,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWallops handles the WALLOPS command.
|
||||||
|
// Broadcasts a message to all users with +w usermode
|
||||||
|
// (oper only).
|
||||||
|
func (hdlr *Handlers) handleWallops(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
// Check oper status.
|
||||||
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNoPrivileges, nick, nil,
|
||||||
|
"Permission Denied- You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdWallops},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
wallopsSIDs, err := hdlr.params.Database.
|
||||||
|
GetWallopsSessionIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"get wallops sessions failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wallopsSIDs) > 0 {
|
||||||
|
body, mErr := json.Marshal([]string{message})
|
||||||
|
if mErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"marshal wallops body", "error", mErr,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = hdlr.fanOutSilent(
|
||||||
|
request, irc.CmdWallops, nick, "*",
|
||||||
|
json.RawMessage(body), wallopsSIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserMode handles user mode queries and changes
|
||||||
|
// (e.g., MODE nick, MODE nick +w).
|
||||||
|
func (hdlr *Handlers) handleUserMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, target string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
|
||||||
|
// Mode change requested.
|
||||||
|
if len(lines) > 0 {
|
||||||
|
// Users can only change their own modes.
|
||||||
|
if target != nick && target != "" {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUsersDoNotMatch, nick, nil,
|
||||||
|
"Can't change mode for other users",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.applyUserModeChange(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, lines[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode query — build the current mode string.
|
||||||
|
modeStr := hdlr.buildUserModeString(ctx, sessionID)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||||
|
modeStr,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserModeString constructs the mode string for a
|
||||||
|
// user (e.g., "+ow" for oper+wallops).
|
||||||
|
func (hdlr *Handlers) buildUserModeString(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) string {
|
||||||
|
modes := "+"
|
||||||
|
|
||||||
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err == nil && isOper {
|
||||||
|
modes += "o"
|
||||||
|
}
|
||||||
|
|
||||||
|
isWallops, err := hdlr.params.Database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err == nil && isWallops {
|
||||||
|
modes += "w"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeChange applies a user mode change string
|
||||||
|
// (e.g., "+w", "-w").
|
||||||
|
func (hdlr *Handlers) applyUserModeChange(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, modeStr string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUmodeUnknownFlag, nick, nil,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adding := modeStr[0] == '+'
|
||||||
|
modeChar := modeStr[1:]
|
||||||
|
|
||||||
|
applied, err := hdlr.applyModeChar(
|
||||||
|
ctx, writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
|
modeChar, adding,
|
||||||
|
)
|
||||||
|
if err != nil || !applied {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newModes := hdlr.buildUserModeString(ctx, sessionID)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||||
|
newModes,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyModeChar applies a single user mode character.
|
||||||
|
// Returns (applied, error).
|
||||||
|
func (hdlr *Handlers) applyModeChar(
|
||||||
|
ctx context.Context,
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, modeChar string,
|
||||||
|
adding bool,
|
||||||
|
) (bool, error) {
|
||||||
|
switch modeChar {
|
||||||
|
case "w":
|
||||||
|
err := hdlr.params.Database.SetSessionWallops(
|
||||||
|
ctx, sessionID, adding,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"set wallops mode failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"set wallops: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "o":
|
||||||
|
// +o cannot be set via MODE, only via OPER.
|
||||||
|
if adding {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUmodeUnknownFlag, nick, nil,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.params.Database.SetSessionOper(
|
||||||
|
ctx, sessionID, false,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"clear oper mode failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"clear oper: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUmodeUnknownFlag, nick, nil,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
982
internal/handlers/utility_test.go
Normal file
982
internal/handlers/utility_test.go
Normal file
@@ -0,0 +1,982 @@
|
|||||||
|
// Tests for Tier 3 utility IRC commands: USERHOST,
|
||||||
|
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest
|
||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- USERHOST ---
|
||||||
|
|
||||||
|
func TestUserhostSingleNick(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("alice")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "USERHOST",
|
||||||
|
bodyKey: []string{"alice"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 302 RPL_USERHOST.
|
||||||
|
msg := findNumericWithParams(msgs, "302")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_USERHOST (302), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body should contain "alice" with the
|
||||||
|
// nick=+user@host format.
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "alice") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected body to contain 'alice', got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// '+' means not away.
|
||||||
|
if !strings.Contains(body, "=+") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected not-away prefix '=+', got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhostMultipleNicks(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token1 := tserver.createSession("bob")
|
||||||
|
token2 := tserver.createSession("carol")
|
||||||
|
|
||||||
|
_ = token2
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token1, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token1, map[string]any{
|
||||||
|
commandKey: "USERHOST",
|
||||||
|
bodyKey: []string{"bob", "carol"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token1, lastID)
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, "302")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_USERHOST (302), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "bob") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected body to contain 'bob', got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, "carol") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected body to contain 'carol', got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhostNonexistentNick(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("dave")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "USERHOST",
|
||||||
|
bodyKey: []string{"nobody"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Should still get 302 but with empty body.
|
||||||
|
msg := findNumericWithParams(msgs, "302")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_USERHOST (302), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhostNoParams(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("eve")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "USERHOST",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhostShowsOper(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("opernick")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Authenticate as oper.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// USERHOST should show '*' for oper.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "USERHOST",
|
||||||
|
bodyKey: []string{"opernick"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, "302")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_USERHOST (302), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "opernick*=") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected oper '*' in reply, got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhostShowsAway(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("awaynick")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Set away.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "AWAY",
|
||||||
|
bodyKey: []string{"gone fishing"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// USERHOST should show '-' for away.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "USERHOST",
|
||||||
|
bodyKey: []string{"awaynick"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, "302")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_USERHOST (302), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "=-") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected away prefix '=-' in reply, got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VERSION ---
|
||||||
|
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("frank")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "VERSION",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 351 RPL_VERSION.
|
||||||
|
msg := findNumericWithParams(msgs, "351")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_VERSION (351), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := getNumericParams(msg)
|
||||||
|
if len(params) == 0 {
|
||||||
|
t.Fatal("expected VERSION params, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First param should contain version string.
|
||||||
|
if !strings.Contains(params[0], "test") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected version to contain 'test', got %q",
|
||||||
|
params[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ADMIN ---
|
||||||
|
|
||||||
|
func TestAdmin(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("grace")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "ADMIN",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 256 RPL_ADMINME.
|
||||||
|
if !findNumeric(msgs, "256") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_ADMINME (256), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect 257 RPL_ADMINLOC1.
|
||||||
|
if !findNumeric(msgs, "257") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_ADMINLOC1 (257), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect 258 RPL_ADMINLOC2.
|
||||||
|
if !findNumeric(msgs, "258") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_ADMINLOC2 (258), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect 259 RPL_ADMINEMAIL.
|
||||||
|
if !findNumeric(msgs, "259") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_ADMINEMAIL (259), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INFO ---
|
||||||
|
|
||||||
|
func TestInfo(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("hank")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "INFO",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 371 RPL_INFO (at least one).
|
||||||
|
if !findNumeric(msgs, "371") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_INFO (371), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect 374 RPL_ENDOFINFO.
|
||||||
|
if !findNumeric(msgs, "374") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_ENDOFINFO (374), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TIME ---
|
||||||
|
|
||||||
|
func TestTime(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("iris")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "TIME",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 391 RPL_TIME.
|
||||||
|
msg := findNumericWithParams(msgs, "391")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_TIME (391), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- KILL ---
|
||||||
|
|
||||||
|
func TestKillSuccess(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create the victim first.
|
||||||
|
victimToken := tserver.createSession("victim")
|
||||||
|
_ = victimToken
|
||||||
|
|
||||||
|
// Create oper user.
|
||||||
|
operToken := tserver.createSession("killer")
|
||||||
|
_, lastID := tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
// Authenticate as oper.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(operToken, lastID)
|
||||||
|
|
||||||
|
// Kill the victim.
|
||||||
|
status, result := tserver.sendCommand(
|
||||||
|
operToken, map[string]any{
|
||||||
|
commandKey: "KILL",
|
||||||
|
bodyKey: []string{"victim", "go away"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d: %v", status, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultStatus, _ := result[statusKey].(string)
|
||||||
|
if resultStatus != "ok" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected status ok, got %v",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the victim's session is gone by trying
|
||||||
|
// to WHOIS them.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "WHOIS",
|
||||||
|
toKey: "victim",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(operToken, lastID)
|
||||||
|
|
||||||
|
// Should get 401 ERR_NOSUCHNICK.
|
||||||
|
if !findNumeric(msgs, "401") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected victim to be gone (401), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillNotOper(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
_ = tserver.createSession("target")
|
||||||
|
|
||||||
|
token := tserver.createSession("notoper")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "KILL",
|
||||||
|
bodyKey: []string{"target", "no reason"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 481 ERR_NOPRIVILEGES.
|
||||||
|
if !findNumeric(msgs, "481") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOPRIVILEGES (481), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillNoParams(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("opertest")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "KILL",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOperKillCommand is a helper that creates an oper
|
||||||
|
// session, authenticates, then sends KILL with the given
|
||||||
|
// target nick, and returns the resulting messages.
|
||||||
|
func sendOperKillCommand(
|
||||||
|
t *testing.T,
|
||||||
|
tserver *testServer,
|
||||||
|
operNick, targetNick string,
|
||||||
|
) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token := tserver.createSession(operNick)
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "KILL",
|
||||||
|
bodyKey: []string{targetNick},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillNonexistentUser(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
msgs := sendOperKillCommand(
|
||||||
|
t, tserver, "opertest2", "ghost",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expect 401 ERR_NOSUCHNICK.
|
||||||
|
if !findNumeric(msgs, "401") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOSUCHNICK (401), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillSelf(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
msgs := sendOperKillCommand(
|
||||||
|
t, tserver, "selfkiller", "selfkiller",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expect 483 ERR_CANTKILLSERVER.
|
||||||
|
if !findNumeric(msgs, "483") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_CANTKILLSERVER (483), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillBroadcastsQuit(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create victim and join a channel.
|
||||||
|
victimToken := tserver.createSession("vuser")
|
||||||
|
|
||||||
|
tserver.sendCommand(victimToken, map[string]any{
|
||||||
|
commandKey: joinCmd,
|
||||||
|
toKey: "#killtest",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create observer and join same channel.
|
||||||
|
observerToken := tserver.createSession("observer")
|
||||||
|
|
||||||
|
tserver.sendCommand(observerToken, map[string]any{
|
||||||
|
commandKey: joinCmd,
|
||||||
|
toKey: "#killtest",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastObs := tserver.pollMessages(observerToken, 0)
|
||||||
|
|
||||||
|
// Create oper.
|
||||||
|
operToken := tserver.createSession("theoper2")
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
// Kill the victim.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "KILL",
|
||||||
|
bodyKey: []string{"vuser", "testing kill"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Observer should see a QUIT message.
|
||||||
|
msgs, _ := tserver.pollMessages(observerToken, lastObs)
|
||||||
|
|
||||||
|
foundQuit := false
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
cmd, _ := msg["command"].(string)
|
||||||
|
if cmd == "QUIT" {
|
||||||
|
from, _ := msg["from"].(string)
|
||||||
|
if from == "vuser" {
|
||||||
|
foundQuit = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundQuit {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected QUIT from vuser, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WALLOPS ---
|
||||||
|
|
||||||
|
func TestWallopsSuccess(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create receiver with +w.
|
||||||
|
receiverToken := tserver.createSession("receiver")
|
||||||
|
|
||||||
|
tserver.sendCommand(receiverToken, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "receiver",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
||||||
|
|
||||||
|
// Create oper.
|
||||||
|
operToken := tserver.createSession("walloper")
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
// Also set +w on oper so they receive it too.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "walloper",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
// Send WALLOPS.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "WALLOPS",
|
||||||
|
bodyKey: []string{"server going down"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Receiver should get the WALLOPS message.
|
||||||
|
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
||||||
|
|
||||||
|
foundWallops := false
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
cmd, _ := msg["command"].(string)
|
||||||
|
if cmd == "WALLOPS" {
|
||||||
|
foundWallops = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundWallops {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected WALLOPS message, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWallopsNotOper(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("notoper2")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "WALLOPS",
|
||||||
|
bodyKey: []string{"hello"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 481 ERR_NOPRIVILEGES.
|
||||||
|
if !findNumeric(msgs, "481") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOPRIVILEGES (481), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWallopsNoParams(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("operempty")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "WALLOPS",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWallopsNotReceivedWithoutW(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create receiver WITHOUT +w.
|
||||||
|
receiverToken := tserver.createSession("nowallops")
|
||||||
|
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
||||||
|
|
||||||
|
// Create oper.
|
||||||
|
operToken := tserver.createSession("walloper2")
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
// Send WALLOPS.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "WALLOPS",
|
||||||
|
bodyKey: []string{"secret message"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Receiver should NOT get the WALLOPS message.
|
||||||
|
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
cmd, _ := msg["command"].(string)
|
||||||
|
if cmd == "WALLOPS" {
|
||||||
|
t.Fatalf(
|
||||||
|
"did not expect WALLOPS for user "+
|
||||||
|
"without +w, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Mode +w ---
|
||||||
|
|
||||||
|
func TestUserModeSetW(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("wmoder")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Set +w.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "wmoder",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, lastID := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 221 RPL_UMODEIS with "+w".
|
||||||
|
msg := findNumericWithParams(msgs, "221")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_UMODEIS (221), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "w") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected mode string to contain 'w', got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now query mode.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "wmoder",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
msg = findNumericWithParams(msgs, "221")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_UMODEIS (221) on query, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body = getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "w") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected mode '+w' in query, got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserModeUnsetW(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("wunsetter")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Set +w first.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "wunsetter",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Unset -w.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "wunsetter",
|
||||||
|
bodyKey: []string{"-w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, "221")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_UMODEIS (221), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if strings.Contains(body, "w") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 'w' to be removed, got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserModeUnknownFlag(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("badmode")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "badmode",
|
||||||
|
bodyKey: []string{"+z"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
||||||
|
if !findNumeric(msgs, "501") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserModeCannotSetO(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("tryoper")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Try to set +o via MODE (should fail).
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "tryoper",
|
||||||
|
bodyKey: []string{"+o"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
||||||
|
if !findNumeric(msgs, "501") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserModeDeoper(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("deoper")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Authenticate as oper.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID = tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Use MODE -o to de-oper.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "deoper",
|
||||||
|
bodyKey: []string{"-o"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, "221")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_UMODEIS (221), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if strings.Contains(body, "o") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 'o' to be removed, got %q",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserModeCannotChangeOtherUser(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
_ = tserver.createSession("other")
|
||||||
|
|
||||||
|
token := tserver.createSession("changer")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Try to change another user's mode.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "other",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 502 ERR_USERSDONTMATCH.
|
||||||
|
if !findNumeric(msgs, "502") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_USERSDONTMATCH (502), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNumericBody extracts the body text from a numeric
|
||||||
|
// message. The body is stored as a JSON array; this
|
||||||
|
// returns the first element.
|
||||||
|
func getNumericBody(msg map[string]any) string {
|
||||||
|
raw, exists := msg["body"]
|
||||||
|
if !exists || raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
arr, isArr := raw.([]any)
|
||||||
|
if !isArr || len(arr) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
str, isStr := arr[0].(string)
|
||||||
|
if !isStr {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testBits = 2
|
const testBits = 2
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Healthcheck.
|
// Params defines the dependencies for creating a Healthcheck.
|
||||||
|
|||||||
@@ -8,10 +8,29 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// versionString returns the server version for IRC
|
||||||
|
// responses, falling back to "neoirc-dev" when globals
|
||||||
|
// are not set (e.g. during tests).
|
||||||
|
func versionString() string {
|
||||||
|
name := globals.Appname
|
||||||
|
ver := globals.Version
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ver == "" {
|
||||||
|
ver = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
return name + "-" + ver
|
||||||
|
}
|
||||||
|
|
||||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||||
// reply on the wire.
|
// reply on the wire.
|
||||||
func (c *Conn) sendIRCError(err error) {
|
func (c *Conn) sendIRCError(err error) {
|
||||||
@@ -118,7 +137,7 @@ func (c *Conn) handlePrivmsg(
|
|||||||
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
|
||||||
|
|
||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
_, err := c.svc.SendChannelMessage(
|
_, _, err := c.svc.SendChannelMessage(
|
||||||
ctx, c.sessionID, c.nick,
|
ctx, c.sessionID, c.nick,
|
||||||
msg.Command, target, body, nil,
|
msg.Command, target, body, nil,
|
||||||
)
|
)
|
||||||
@@ -179,7 +198,7 @@ func (c *Conn) joinChannel(
|
|||||||
channel string,
|
channel string,
|
||||||
) {
|
) {
|
||||||
result, err := c.svc.JoinChannel(
|
result, err := c.svc.JoinChannel(
|
||||||
ctx, c.sessionID, c.nick, channel,
|
ctx, c.sessionID, c.nick, channel, "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -431,7 +450,7 @@ func (c *Conn) handleMode(
|
|||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
c.handleChannelMode(ctx, msg)
|
c.handleChannelMode(ctx, msg)
|
||||||
} else {
|
} else {
|
||||||
c.handleUserMode(msg)
|
c.handleUserMode(ctx, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +509,124 @@ func (c *Conn) handleChannelMode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modeResult holds the delta strings produced by a
|
||||||
|
// single mode-char application.
|
||||||
|
type modeResult struct {
|
||||||
|
applied string
|
||||||
|
appliedArgs string
|
||||||
|
consumed int
|
||||||
|
skip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyHashcashMode handles +H/-H (hashcash difficulty).
|
||||||
|
func (c *Conn) applyHashcashMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
adding bool,
|
||||||
|
args []string,
|
||||||
|
argIdx int,
|
||||||
|
) modeResult {
|
||||||
|
if !adding {
|
||||||
|
_ = c.database.SetChannelHashcashBits(
|
||||||
|
ctx, chID, 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "-H",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 0,
|
||||||
|
skip: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if argIdx >= len(args) {
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 0,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bitsStr := args[argIdx]
|
||||||
|
|
||||||
|
bits, parseErr := strconv.Atoi(bitsStr)
|
||||||
|
if parseErr != nil ||
|
||||||
|
bits < 1 || bits > maxHashcashBits {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUnknownMode, "H",
|
||||||
|
"is unknown mode char to me",
|
||||||
|
)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 1,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.database.SetChannelHashcashBits(
|
||||||
|
ctx, chID, bits,
|
||||||
|
)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "+H",
|
||||||
|
appliedArgs: " " + bitsStr,
|
||||||
|
consumed: 1,
|
||||||
|
skip: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMemberMode handles +o/-o and +v/-v.
|
||||||
|
func (c *Conn) applyMemberMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
channel string,
|
||||||
|
modeChar rune,
|
||||||
|
adding bool,
|
||||||
|
args []string,
|
||||||
|
argIdx int,
|
||||||
|
) modeResult {
|
||||||
|
if argIdx >= len(args) {
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 0,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetNick := args[argIdx]
|
||||||
|
|
||||||
|
err := c.svc.ApplyMemberMode(
|
||||||
|
ctx, chID, channel,
|
||||||
|
targetNick, modeChar, adding,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.sendIRCError(err)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 1,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "+"
|
||||||
|
if !adding {
|
||||||
|
prefix = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: prefix + string(modeChar),
|
||||||
|
appliedArgs: " " + targetNick,
|
||||||
|
consumed: 1,
|
||||||
|
skip: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyChannelModes applies mode changes using the
|
// applyChannelModes applies mode changes using the
|
||||||
// service for individual mode operations.
|
// service for individual mode operations.
|
||||||
func (c *Conn) applyChannelModes(
|
func (c *Conn) applyChannelModes(
|
||||||
@@ -505,52 +642,57 @@ func (c *Conn) applyChannelModes(
|
|||||||
appliedArgs := ""
|
appliedArgs := ""
|
||||||
|
|
||||||
for _, modeChar := range modeStr {
|
for _, modeChar := range modeStr {
|
||||||
|
var res modeResult
|
||||||
|
|
||||||
switch modeChar {
|
switch modeChar {
|
||||||
case '+':
|
case '+':
|
||||||
adding = true
|
adding = true
|
||||||
|
|
||||||
|
continue
|
||||||
case '-':
|
case '-':
|
||||||
adding = false
|
adding = false
|
||||||
case 'm', 't':
|
|
||||||
|
continue
|
||||||
|
case 'i', 'm', 'n', 's', 't':
|
||||||
_ = c.svc.SetChannelFlag(
|
_ = c.svc.SetChannelFlag(
|
||||||
ctx, chID, modeChar, adding,
|
ctx, chID, modeChar, adding,
|
||||||
)
|
)
|
||||||
|
|
||||||
if adding {
|
prefix := "+"
|
||||||
applied += "+" + string(modeChar)
|
if !adding {
|
||||||
} else {
|
prefix = "-"
|
||||||
applied += "-" + string(modeChar)
|
|
||||||
}
|
|
||||||
case 'o', 'v':
|
|
||||||
if argIdx >= len(args) {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
targetNick := args[argIdx]
|
res = modeResult{
|
||||||
argIdx++
|
applied: prefix + string(modeChar),
|
||||||
|
appliedArgs: "",
|
||||||
err := c.svc.ApplyMemberMode(
|
consumed: 0,
|
||||||
ctx, chID, channel,
|
skip: false,
|
||||||
targetNick, modeChar, adding,
|
}
|
||||||
|
case 'H':
|
||||||
|
res = c.applyHashcashMode(
|
||||||
|
ctx, chID, adding, args, argIdx,
|
||||||
|
)
|
||||||
|
case 'o', 'v':
|
||||||
|
res = c.applyMemberMode(
|
||||||
|
ctx, chID, channel,
|
||||||
|
modeChar, adding, args, argIdx,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
c.sendIRCError(err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if adding {
|
|
||||||
applied += "+" + string(modeChar)
|
|
||||||
} else {
|
|
||||||
applied += "-" + string(modeChar)
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedArgs += " " + targetNick
|
|
||||||
default:
|
default:
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrUnknownMode,
|
irc.ErrUnknownMode,
|
||||||
string(modeChar),
|
string(modeChar),
|
||||||
"is unknown mode char to me",
|
"is unknown mode char to me",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
argIdx += res.consumed
|
||||||
|
|
||||||
|
if !res.skip {
|
||||||
|
applied += res.applied
|
||||||
|
appliedArgs += res.appliedArgs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,7 +713,10 @@ func (c *Conn) applyChannelModes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserMode handles MODE for users.
|
// handleUserMode handles MODE for users.
|
||||||
func (c *Conn) handleUserMode(msg *Message) {
|
func (c *Conn) handleUserMode(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
target := msg.Params[0]
|
target := msg.Params[0]
|
||||||
|
|
||||||
if !strings.EqualFold(target, c.nick) {
|
if !strings.EqualFold(target, c.nick) {
|
||||||
@@ -583,8 +728,85 @@ func (c *Conn) handleUserMode(msg *Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't support user modes beyond the basics.
|
// Mode query (no mode string).
|
||||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
if len(msg.Params) < 2 { //nolint:mnd
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplUmodeIs,
|
||||||
|
c.buildUmodeString(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modeStr := msg.Params[1]
|
||||||
|
|
||||||
|
if len(modeStr) < 2 { //nolint:mnd
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUmodeUnknownFlag,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adding := modeStr[0] == '+'
|
||||||
|
|
||||||
|
for _, ch := range modeStr[1:] {
|
||||||
|
switch ch {
|
||||||
|
case 'w':
|
||||||
|
_ = c.database.SetSessionWallops(
|
||||||
|
ctx, c.sessionID, adding,
|
||||||
|
)
|
||||||
|
case 'o':
|
||||||
|
if adding {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUmodeUnknownFlag,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.database.SetSessionOper(
|
||||||
|
ctx, c.sessionID, false,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUmodeUnknownFlag,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplUmodeIs,
|
||||||
|
c.buildUmodeString(ctx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUmodeString returns the current user mode string.
|
||||||
|
func (c *Conn) buildUmodeString(
|
||||||
|
ctx context.Context,
|
||||||
|
) string {
|
||||||
|
modes := "+"
|
||||||
|
|
||||||
|
isOper, err := c.database.IsSessionOper(
|
||||||
|
ctx, c.sessionID,
|
||||||
|
)
|
||||||
|
if err == nil && isOper {
|
||||||
|
modes += "o"
|
||||||
|
}
|
||||||
|
|
||||||
|
isWallops, err := c.database.IsSessionWallops(
|
||||||
|
ctx, c.sessionID,
|
||||||
|
)
|
||||||
|
if err == nil && isWallops {
|
||||||
|
modes += "w"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNames replies with channel member list.
|
// handleNames replies with channel member list.
|
||||||
@@ -1176,3 +1398,191 @@ func (c *Conn) handleUserhost(
|
|||||||
strings.Join(replies, " "),
|
strings.Join(replies, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleVersion replies with the server version string.
|
||||||
|
func (c *Conn) handleVersion(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
version := versionString()
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplVersion,
|
||||||
|
version+".", c.cfg.ServerName,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdmin replies with server admin info.
|
||||||
|
func (c *Conn) handleAdmin(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
srvName := c.cfg.ServerName
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminMe,
|
||||||
|
srvName, "Administrative info",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminLoc1,
|
||||||
|
"neoirc server",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminLoc2,
|
||||||
|
"IRC over HTTP",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminEmail,
|
||||||
|
"admin@"+srvName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo replies with server software info.
|
||||||
|
func (c *Conn) handleInfo(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
infoLines := []string{
|
||||||
|
"neoirc — IRC semantics over HTTP",
|
||||||
|
"Version: " + versionString(),
|
||||||
|
"Written in Go",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range infoLines {
|
||||||
|
c.sendNumeric(irc.RplInfo, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplEndOfInfo,
|
||||||
|
"End of /INFO list",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime replies with the server's current time.
|
||||||
|
func (c *Conn) handleTime(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
srvName := c.cfg.ServerName
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplTime,
|
||||||
|
srvName, time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKillCmd forcibly disconnects a target user (oper
|
||||||
|
// only).
|
||||||
|
func (c *Conn) handleKillCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
isOper, err := c.database.IsSessionOper(
|
||||||
|
ctx, c.sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoPrivileges,
|
||||||
|
"Permission Denied- "+
|
||||||
|
"You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"KILL", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetNick := msg.Params[0]
|
||||||
|
|
||||||
|
reason := "KILLed"
|
||||||
|
if len(msg.Params) > 1 {
|
||||||
|
reason = msg.Params[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetNick == c.nick {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrCantKillServer,
|
||||||
|
"You cannot KILL yourself",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, lookupErr := c.database.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if lookupErr != nil {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
targetNick, "No such nick/channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quitReason := "Killed (" + c.nick + " (" + reason + "))"
|
||||||
|
|
||||||
|
c.svc.BroadcastQuit(
|
||||||
|
ctx, targetSID, targetNick, quitReason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWallopsCmd broadcasts to all +w users (oper only).
|
||||||
|
func (c *Conn) handleWallopsCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
isOper, err := c.database.IsSessionOper(
|
||||||
|
ctx, c.sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoPrivileges,
|
||||||
|
"Permission Denied- "+
|
||||||
|
"You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"WALLOPS", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := msg.Params[0]
|
||||||
|
|
||||||
|
wallopsSIDs, wallErr := c.database.
|
||||||
|
GetWallopsSessionIDs(ctx)
|
||||||
|
if wallErr != nil {
|
||||||
|
c.log.Error(
|
||||||
|
"get wallops sessions failed",
|
||||||
|
"error", wallErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wallopsSIDs) > 0 {
|
||||||
|
body, mErr := json.Marshal([]string{message})
|
||||||
|
if mErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _ = c.svc.FanOut(
|
||||||
|
ctx, irc.CmdWallops, c.nick, "*",
|
||||||
|
nil, body, nil, wallopsSIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,23 +11,24 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxLineLen = 512
|
maxLineLen = 512
|
||||||
readTimeout = 5 * time.Minute
|
readTimeout = 5 * time.Minute
|
||||||
writeTimeout = 30 * time.Second
|
writeTimeout = 30 * time.Second
|
||||||
dnsTimeout = 3 * time.Second
|
dnsTimeout = 3 * time.Second
|
||||||
pollInterval = 100 * time.Millisecond
|
pollInterval = 100 * time.Millisecond
|
||||||
pingInterval = 90 * time.Second
|
pingInterval = 90 * time.Second
|
||||||
pongDeadline = 30 * time.Second
|
pongDeadline = 30 * time.Second
|
||||||
maxNickLen = 32
|
maxNickLen = 32
|
||||||
minPasswordLen = 8
|
minPasswordLen = 8
|
||||||
|
maxHashcashBits = 40
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdHandler is the signature for registered IRC command
|
// cmdHandler is the signature for registered IRC command
|
||||||
@@ -129,7 +130,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
|||||||
"CAP": func(_ context.Context, msg *Message) {
|
"CAP": func(_ context.Context, msg *Message) {
|
||||||
c.handleCAP(msg)
|
c.handleCAP(msg)
|
||||||
},
|
},
|
||||||
"USERHOST": c.handleUserhost,
|
"USERHOST": c.handleUserhost,
|
||||||
|
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
|
||||||
|
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
|
||||||
|
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
|
||||||
|
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
|
||||||
|
irc.CmdKill: c.handleKillCmd,
|
||||||
|
irc.CmdWallops: c.handleWallopsCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +441,7 @@ func (c *Conn) deliverWelcome() {
|
|||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
"PREFIX=(ov)@+",
|
"PREFIX=(ov)@+",
|
||||||
"CHANMODES=,,H,mnst",
|
"CHANMODES=,,H,imnst",
|
||||||
"NETWORK="+c.serverSfx,
|
"NETWORK="+c.serverSfx,
|
||||||
"are supported by this server",
|
"are supported by this server",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestServer creates a Server suitable for testing.
|
// NewTestServer creates a Server suitable for testing.
|
||||||
@@ -19,12 +19,9 @@ func NewTestServer(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
) *Server {
|
) *Server {
|
||||||
svc := &service.Service{
|
svc := service.NewTestService(
|
||||||
DB: database,
|
database, brk, cfg, log,
|
||||||
Broker: brk,
|
)
|
||||||
Config: cfg,
|
|
||||||
Log: log,
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Server{ //nolint:exhaustruct
|
return &Server{ //nolint:exhaustruct
|
||||||
log: log,
|
log: log,
|
||||||
|
|||||||
1195
internal/ircserver/integration_test.go
Normal file
1195
internal/ircserver/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ package ircserver_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:funlen // table-driven test
|
//nolint:funlen // table-driven test
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// relayMessages polls the client output queue and delivers
|
// relayMessages polls the client output queue and delivers
|
||||||
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
|
|||||||
c.deliverKickMsg(msg, text)
|
c.deliverKickMsg(msg, text)
|
||||||
case command == "INVITE":
|
case command == "INVITE":
|
||||||
c.deliverInviteMsg(msg, text)
|
c.deliverInviteMsg(msg, text)
|
||||||
|
case command == irc.CmdWallops:
|
||||||
|
c.deliverWallops(msg, text)
|
||||||
case command == irc.CmdMode:
|
case command == irc.CmdMode:
|
||||||
c.deliverMode(msg, text)
|
c.deliverMode(msg, text)
|
||||||
case command == irc.CmdPing:
|
case command == irc.CmdPing:
|
||||||
@@ -305,6 +307,18 @@ func (c *Conn) deliverInviteMsg(
|
|||||||
c.sendFromServer("NOTICE", c.nick, text)
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverWallops sends a WALLOPS notification.
|
||||||
|
func (c *Conn) deliverWallops(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, irc.CmdWallops, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// deliverMode sends a MODE change notification.
|
// deliverMode sends a MODE change notification.
|
||||||
func (c *Conn) deliverMode(
|
func (c *Conn) deliverMode(
|
||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating an IRC
|
// Params defines the dependencies for creating an IRC
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -112,6 +112,87 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestEnvWithOper creates a test environment with oper
|
||||||
|
// credentials configured.
|
||||||
|
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
|
t.Name(),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
t.Context(), "PRAGMA foreign_keys = ON",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database := db.NewTestDatabaseFromConn(conn)
|
||||||
|
|
||||||
|
err = database.RunMigrations(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
cfg := &config.Config{ //nolint:exhaustruct
|
||||||
|
ServerName: "test.irc",
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
OperName: "testoper",
|
||||||
|
OperPassword: "testpass",
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
|
||||||
|
err = listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||||
|
))
|
||||||
|
|
||||||
|
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||||
|
|
||||||
|
err = srv.Start(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start irc server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
srv.Stop()
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
srv: srv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dial connects to the test server.
|
// dial connects to the test server.
|
||||||
func (env *testEnv) dial(t *testing.T) *testClient {
|
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Logger.
|
// Params defines the dependencies for creating a Logger.
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
@@ -18,6 +15,9 @@ import (
|
|||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const corsMaxAge = 300
|
const corsMaxAge = 300
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ratelimit_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewCreatesLimiter(t *testing.T) {
|
func TestNewCreatesLimiter(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/web"
|
"sneak.berlin/go/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Service.
|
// Params defines the dependencies for creating a Service.
|
||||||
@@ -29,19 +31,35 @@ type Params struct {
|
|||||||
|
|
||||||
// Service provides shared business logic for IRC commands.
|
// Service provides shared business logic for IRC commands.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
DB *db.Database
|
db *db.Database
|
||||||
Broker *broker.Broker
|
broker *broker.Broker
|
||||||
Config *config.Config
|
config *config.Config
|
||||||
Log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Service.
|
// New creates a new Service.
|
||||||
func New(params Params) *Service {
|
func New(params Params) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
DB: params.Database,
|
db: params.Database,
|
||||||
Broker: params.Broker,
|
broker: params.Broker,
|
||||||
Config: params.Config,
|
config: params.Config,
|
||||||
Log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestService creates a Service for use in tests
|
||||||
|
// outside the service package.
|
||||||
|
func NewTestService(
|
||||||
|
database *db.Database,
|
||||||
|
brk *broker.Broker,
|
||||||
|
cfg *config.Config,
|
||||||
|
log *slog.Logger,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: database,
|
||||||
|
broker: brk,
|
||||||
|
config: cfg,
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +93,7 @@ func (s *Service) FanOut(
|
|||||||
params, body, meta json.RawMessage,
|
params, body, meta json.RawMessage,
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
dbID, msgUUID, err := s.DB.InsertMessage(
|
dbID, msgUUID, err := s.db.InsertMessage(
|
||||||
ctx, command, from, to, params, body, meta,
|
ctx, command, from, to, params, body, meta,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,8 +101,8 @@ func (s *Service) FanOut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, sid := range sessionIDs {
|
for _, sid := range sessionIDs {
|
||||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
_ = s.db.EnqueueToSession(ctx, sid, dbID)
|
||||||
s.Broker.Notify(sid)
|
s.broker.Notify(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbID, msgUUID, nil
|
return dbID, msgUUID, nil
|
||||||
@@ -109,44 +127,59 @@ func excludeSession(
|
|||||||
|
|
||||||
// SendChannelMessage validates membership and moderation,
|
// SendChannelMessage validates membership and moderation,
|
||||||
// then fans out a message to all channel members except
|
// then fans out a message to all channel members except
|
||||||
// the sender.
|
// the sender. Returns the database row ID, message UUID,
|
||||||
|
// and any error. The dbID lets callers enqueue the same
|
||||||
|
// message to the sender when echo is needed (HTTP
|
||||||
|
// transport).
|
||||||
func (s *Service) SendChannelMessage(
|
func (s *Service) SendChannelMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, command, channel string,
|
nick, command, channel string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (string, error) {
|
) (int64, string, error) {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", &IRCError{
|
return 0, "", &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
return "", &IRCError{
|
return 0, "", &IRCError{
|
||||||
irc.ErrCannotSendToChan,
|
irc.ErrCannotSendToChan,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"Cannot send to channel",
|
"Cannot send to channel",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
// Ban check — banned users cannot send messages.
|
||||||
|
isBanned, banErr := s.db.IsSessionBanned(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if banErr == nil && isBanned {
|
||||||
|
return 0, "", &IRCError{
|
||||||
|
irc.ErrCannotSendToChan,
|
||||||
|
[]string{channel},
|
||||||
|
"Cannot send to channel (+b)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
||||||
if moderated {
|
if moderated {
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
isVoiced, _ := s.DB.IsChannelVoiced(
|
isVoiced, _ := s.db.IsChannelVoiced(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if !isOp && !isVoiced {
|
if !isOp && !isVoiced {
|
||||||
return "", &IRCError{
|
return 0, "", &IRCError{
|
||||||
irc.ErrCannotSendToChan,
|
irc.ErrCannotSendToChan,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"Cannot send to channel (+m)",
|
"Cannot send to channel (+m)",
|
||||||
@@ -154,18 +187,18 @@ func (s *Service) SendChannelMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
recipients := excludeSession(memberIDs, sessionID)
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
|
||||||
_, uuid, fanErr := s.FanOut(
|
dbID, uuid, fanErr := s.FanOut(
|
||||||
ctx, command, nick, channel,
|
ctx, command, nick, channel,
|
||||||
nil, body, meta, recipients,
|
nil, body, meta, recipients,
|
||||||
)
|
)
|
||||||
if fanErr != nil {
|
if fanErr != nil {
|
||||||
return "", fanErr
|
return 0, "", fanErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return uuid, nil
|
return dbID, uuid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDirectMessage validates the target and sends a
|
// SendDirectMessage validates the target and sends a
|
||||||
@@ -177,7 +210,7 @@ func (s *Service) SendDirectMessage(
|
|||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (*DirectMsgResult, error) {
|
) (*DirectMsgResult, error) {
|
||||||
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
targetSID, err := s.db.GetSessionByNick(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &IRCError{
|
return nil, &IRCError{
|
||||||
irc.ErrNoSuchNick,
|
irc.ErrNoSuchNick,
|
||||||
@@ -186,7 +219,7 @@ func (s *Service) SendDirectMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
away, _ := s.DB.GetAway(ctx, targetSID)
|
away, _ := s.db.GetAway(ctx, targetSID)
|
||||||
|
|
||||||
recipients := []int64{targetSID}
|
recipients := []int64{targetSID}
|
||||||
if targetSID != sessionID {
|
if targetSID != sessionID {
|
||||||
@@ -210,31 +243,43 @@ func (s *Service) SendDirectMessage(
|
|||||||
func (s *Service) JoinChannel(
|
func (s *Service) JoinChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel string,
|
nick, channel, suppliedKey string,
|
||||||
) (*JoinResult, error) {
|
) (*JoinResult, error) {
|
||||||
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
chID, err := s.db.GetOrCreateChannel(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get/create channel: %w", err)
|
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
memberCount, countErr := s.DB.CountChannelMembers(
|
memberCount, countErr := s.db.CountChannelMembers(
|
||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
isCreator := countErr == nil && memberCount == 0
|
isCreator := countErr == nil && memberCount == 0
|
||||||
|
|
||||||
|
if !isCreator {
|
||||||
|
if joinErr := checkJoinRestrictions(
|
||||||
|
ctx, s.db, chID, sessionID,
|
||||||
|
channel, suppliedKey, memberCount,
|
||||||
|
); joinErr != nil {
|
||||||
|
return nil, joinErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isCreator {
|
if isCreator {
|
||||||
err = s.DB.JoinChannelAsOperator(
|
err = s.db.JoinChannelAsOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
err = s.db.JoinChannel(ctx, chID, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("join channel: %w", err)
|
return nil, fmt.Errorf("join channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
// Clear invite after successful join.
|
||||||
|
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
|
||||||
|
|
||||||
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
@@ -256,7 +301,7 @@ func (s *Service) PartChannel(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel, reason string,
|
nick, channel, reason string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -265,7 +310,7 @@ func (s *Service) PartChannel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -276,7 +321,7 @@ func (s *Service) PartChannel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
recipients := excludeSession(memberIDs, sessionID)
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
@@ -285,8 +330,8 @@ func (s *Service) PartChannel(
|
|||||||
nil, body, nil, recipients,
|
nil, body, nil, recipients,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -298,7 +343,7 @@ func (s *Service) SetTopic(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel, topic string,
|
nick, channel, topic string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -307,7 +352,7 @@ func (s *Service) SetTopic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -318,9 +363,9 @@ func (s *Service) SetTopic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
||||||
if topicLocked {
|
if topicLocked {
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isOp {
|
if !isOp {
|
||||||
@@ -332,15 +377,15 @@ func (s *Service) SetTopic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if setErr := s.DB.SetTopic(
|
if setErr := s.db.SetTopic(
|
||||||
ctx, channel, topic,
|
ctx, channel, topic,
|
||||||
); setErr != nil {
|
); setErr != nil {
|
||||||
return fmt.Errorf("set topic: %w", setErr)
|
return fmt.Errorf("set topic: %w", setErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
_ = s.db.SetTopicMeta(ctx, channel, topic, nick)
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
@@ -359,7 +404,7 @@ func (s *Service) KickUser(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel, targetNick, reason string,
|
nick, channel, targetNick, reason string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -368,7 +413,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isOp {
|
if !isOp {
|
||||||
@@ -379,7 +424,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
targetSID, err := s.DB.GetSessionByNick(
|
targetSID, err := s.db.GetSessionByNick(
|
||||||
ctx, targetNick,
|
ctx, targetNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -390,7 +435,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, targetSID,
|
ctx, chID, targetSID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -401,7 +446,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
params, _ := json.Marshal( //nolint:errchkjson
|
params, _ := json.Marshal( //nolint:errchkjson
|
||||||
[]string{targetNick},
|
[]string{targetNick},
|
||||||
@@ -412,8 +457,8 @@ func (s *Service) KickUser(
|
|||||||
params, body, nil, memberIDs,
|
params, body, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -425,7 +470,7 @@ func (s *Service) ChangeNick(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
oldNick, newNick string,
|
oldNick, newNick string,
|
||||||
) error {
|
) error {
|
||||||
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
err := s.db.ChangeNick(ctx, sessionID, newNick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") ||
|
if strings.Contains(err.Error(), "UNIQUE") ||
|
||||||
db.IsUniqueConstraintError(err) {
|
db.IsUniqueConstraintError(err) {
|
||||||
@@ -449,13 +494,15 @@ func (s *Service) ChangeNick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
||||||
// parts all channels, and deletes the session.
|
// parts all channels, and deletes the session. Uses the
|
||||||
|
// FanOut pattern: one message row fanned out to all unique
|
||||||
|
// peer sessions.
|
||||||
func (s *Service) BroadcastQuit(
|
func (s *Service) BroadcastQuit(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, reason string,
|
nick, reason string,
|
||||||
) {
|
) {
|
||||||
channels, err := s.DB.GetSessionChannels(
|
channels, err := s.db.GetSessionChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -465,7 +512,7 @@ func (s *Service) BroadcastQuit(
|
|||||||
notified := make(map[int64]bool)
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
memberIDs, memErr := s.db.GetChannelMemberIDs(
|
||||||
ctx, ch.ID,
|
ctx, ch.ID,
|
||||||
)
|
)
|
||||||
if memErr != nil {
|
if memErr != nil {
|
||||||
@@ -481,27 +528,26 @@ func (s *Service) BroadcastQuit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
if len(notified) > 0 {
|
||||||
|
recipients := make([]int64, 0, len(notified))
|
||||||
for sid := range notified {
|
for sid := range notified {
|
||||||
dbID, _, insErr := s.DB.InsertMessage(
|
recipients = append(recipients, sid)
|
||||||
ctx, irc.CmdQuit, nick, "",
|
|
||||||
nil, body, nil,
|
|
||||||
)
|
|
||||||
if insErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
s.Broker.Notify(sid)
|
|
||||||
|
_, _, _ = s.FanOut(
|
||||||
|
ctx, irc.CmdQuit, nick, "",
|
||||||
|
nil, body, nil, recipients,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
||||||
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAway sets or clears the away message. Returns true
|
// SetAway sets or clears the away message. Returns true
|
||||||
@@ -511,7 +557,7 @@ func (s *Service) SetAway(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
message string,
|
message string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
err := s.DB.SetAway(ctx, sessionID, message)
|
err := s.db.SetAway(ctx, sessionID, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("set away: %w", err)
|
return false, fmt.Errorf("set away: %w", err)
|
||||||
}
|
}
|
||||||
@@ -526,10 +572,19 @@ func (s *Service) Oper(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
name, password string,
|
name, password string,
|
||||||
) error {
|
) error {
|
||||||
cfgName := s.Config.OperName
|
cfgName := s.config.OperName
|
||||||
cfgPassword := s.Config.OperPassword
|
cfgPassword := s.config.OperPassword
|
||||||
|
|
||||||
if cfgName == "" || cfgPassword == "" {
|
// Use constant-time comparison and return the same
|
||||||
|
// error for all failures to prevent information
|
||||||
|
// leakage about valid operator names.
|
||||||
|
if cfgName == "" || cfgPassword == "" ||
|
||||||
|
subtle.ConstantTimeCompare(
|
||||||
|
[]byte(name), []byte(cfgName),
|
||||||
|
) != 1 ||
|
||||||
|
subtle.ConstantTimeCompare(
|
||||||
|
[]byte(password), []byte(cfgPassword),
|
||||||
|
) != 1 {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoOperHost,
|
irc.ErrNoOperHost,
|
||||||
nil,
|
nil,
|
||||||
@@ -537,15 +592,7 @@ func (s *Service) Oper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if name != cfgName || password != cfgPassword {
|
_ = s.db.SetSessionOper(ctx, sessionID, true)
|
||||||
return &IRCError{
|
|
||||||
irc.ErrPasswdMismatch,
|
|
||||||
nil,
|
|
||||||
"Password incorrect",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -557,7 +604,7 @@ func (s *Service) ValidateChannelOp(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
channel string,
|
channel string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, &IRCError{
|
return 0, &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -566,7 +613,7 @@ func (s *Service) ValidateChannelOp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isOp {
|
if !isOp {
|
||||||
@@ -589,7 +636,7 @@ func (s *Service) ApplyMemberMode(
|
|||||||
mode rune,
|
mode rune,
|
||||||
adding bool,
|
adding bool,
|
||||||
) error {
|
) error {
|
||||||
targetSID, err := s.DB.GetSessionByNick(
|
targetSID, err := s.db.GetSessionByNick(
|
||||||
ctx, targetNick,
|
ctx, targetNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -600,7 +647,7 @@ func (s *Service) ApplyMemberMode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, targetSID,
|
ctx, chID, targetSID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -613,11 +660,11 @@ func (s *Service) ApplyMemberMode(
|
|||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case 'o':
|
case 'o':
|
||||||
_ = s.DB.SetChannelMemberOperator(
|
_ = s.db.SetChannelMemberOperator(
|
||||||
ctx, chID, targetSID, adding,
|
ctx, chID, targetSID, adding,
|
||||||
)
|
)
|
||||||
case 'v':
|
case 'v':
|
||||||
_ = s.DB.SetChannelMemberVoiced(
|
_ = s.db.SetChannelMemberVoiced(
|
||||||
ctx, chID, targetSID, adding,
|
ctx, chID, targetSID, adding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -625,7 +672,8 @@ func (s *Service) ApplyMemberMode(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
// SetChannelFlag applies a simple boolean channel mode
|
||||||
|
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
|
||||||
func (s *Service) SetChannelFlag(
|
func (s *Service) SetChannelFlag(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chID int64,
|
chID int64,
|
||||||
@@ -634,17 +682,37 @@ func (s *Service) SetChannelFlag(
|
|||||||
) error {
|
) error {
|
||||||
switch flag {
|
switch flag {
|
||||||
case 'm':
|
case 'm':
|
||||||
if err := s.DB.SetChannelModerated(
|
if err := s.db.SetChannelModerated(
|
||||||
ctx, chID, setting,
|
ctx, chID, setting,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("set moderated: %w", err)
|
return fmt.Errorf("set moderated: %w", err)
|
||||||
}
|
}
|
||||||
case 't':
|
case 't':
|
||||||
if err := s.DB.SetChannelTopicLocked(
|
if err := s.db.SetChannelTopicLocked(
|
||||||
ctx, chID, setting,
|
ctx, chID, setting,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("set topic locked: %w", err)
|
return fmt.Errorf("set topic locked: %w", err)
|
||||||
}
|
}
|
||||||
|
case 'i':
|
||||||
|
if err := s.db.SetChannelInviteOnly(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set invite only: %w", err)
|
||||||
|
}
|
||||||
|
case 's':
|
||||||
|
if err := s.db.SetChannelSecret(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set secret: %w", err)
|
||||||
|
}
|
||||||
|
case 'n':
|
||||||
|
if err := s.db.SetChannelNoExternal(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"set no external: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -658,7 +726,7 @@ func (s *Service) BroadcastMode(
|
|||||||
chID int64,
|
chID int64,
|
||||||
modeText string,
|
modeText string,
|
||||||
) {
|
) {
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
@@ -667,24 +735,60 @@ func (s *Service) BroadcastMode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryChannelMode returns the channel mode string.
|
// QueryChannelMode returns the complete channel mode
|
||||||
|
// string including all flags and parameterized modes.
|
||||||
func (s *Service) QueryChannelMode(
|
func (s *Service) QueryChannelMode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chID int64,
|
chID int64,
|
||||||
) string {
|
) string {
|
||||||
modes := "+"
|
modes := "+"
|
||||||
|
|
||||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
noExternal, _ := s.db.IsChannelNoExternal(ctx, chID)
|
||||||
|
if noExternal {
|
||||||
|
modes += "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteOnly, _ := s.db.IsChannelInviteOnly(ctx, chID)
|
||||||
|
if inviteOnly {
|
||||||
|
modes += "i"
|
||||||
|
}
|
||||||
|
|
||||||
|
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
||||||
if moderated {
|
if moderated {
|
||||||
modes += "m"
|
modes += "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
secret, _ := s.db.IsChannelSecret(ctx, chID)
|
||||||
|
if secret {
|
||||||
|
modes += "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
||||||
if topicLocked {
|
if topicLocked {
|
||||||
modes += "t"
|
modes += "t"
|
||||||
}
|
}
|
||||||
|
|
||||||
return modes
|
var modeParams string
|
||||||
|
|
||||||
|
key, _ := s.db.GetChannelKey(ctx, chID)
|
||||||
|
if key != "" {
|
||||||
|
modes += "k"
|
||||||
|
modeParams += " " + key
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := s.db.GetChannelUserLimit(ctx, chID)
|
||||||
|
if limit > 0 {
|
||||||
|
modes += "l"
|
||||||
|
modeParams += " " + strconv.Itoa(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
bits, _ := s.db.GetChannelHashcashBits(ctx, chID)
|
||||||
|
if bits > 0 {
|
||||||
|
modes += "H"
|
||||||
|
modeParams += " " + strconv.Itoa(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes + modeParams
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastNickChange notifies channel peers of a nick
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
@@ -694,7 +798,7 @@ func (s *Service) broadcastNickChange(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
oldNick, newNick string,
|
oldNick, newNick string,
|
||||||
) {
|
) {
|
||||||
channels, err := s.DB.GetSessionChannels(
|
channels, err := s.db.GetSessionChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -704,7 +808,7 @@ func (s *Service) broadcastNickChange(
|
|||||||
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
||||||
notified := make(map[int64]bool)
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
dbID, _, insErr := s.DB.InsertMessage(
|
dbID, _, insErr := s.db.InsertMessage(
|
||||||
ctx, irc.CmdNick, oldNick, "",
|
ctx, irc.CmdNick, oldNick, "",
|
||||||
nil, body, nil,
|
nil, body, nil,
|
||||||
)
|
)
|
||||||
@@ -713,12 +817,12 @@ func (s *Service) broadcastNickChange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user themselves (for multi-client sync).
|
// Notify the user themselves (for multi-client sync).
|
||||||
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
|
||||||
s.Broker.Notify(sessionID)
|
s.broker.Notify(sessionID)
|
||||||
notified[sessionID] = true
|
notified[sessionID] = true
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
memberIDs, memErr := s.db.GetChannelMemberIDs(
|
||||||
ctx, ch.ID,
|
ctx, ch.ID,
|
||||||
)
|
)
|
||||||
if memErr != nil {
|
if memErr != nil {
|
||||||
@@ -732,8 +836,66 @@ func (s *Service) broadcastNickChange(
|
|||||||
|
|
||||||
notified[mid] = true
|
notified[mid] = true
|
||||||
|
|
||||||
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
_ = s.db.EnqueueToSession(ctx, mid, dbID)
|
||||||
s.Broker.Notify(mid)
|
s.broker.Notify(mid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkJoinRestrictions validates Tier 2 join conditions:
|
||||||
|
// bans, invite-only, channel key, and user limit.
|
||||||
|
func checkJoinRestrictions(
|
||||||
|
ctx context.Context,
|
||||||
|
database *db.Database,
|
||||||
|
chID, sessionID int64,
|
||||||
|
channel, suppliedKey string,
|
||||||
|
memberCount int64,
|
||||||
|
) error {
|
||||||
|
isBanned, banErr := database.IsSessionBanned(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if banErr == nil && isBanned {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrBannedFromChan,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+b)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInviteOnly, ioErr := database.IsChannelInviteOnly(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
if ioErr == nil && isInviteOnly {
|
||||||
|
hasInvite, invErr := database.HasChannelInvite(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if invErr != nil || !hasInvite {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrInviteOnlyChan,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+i)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key, keyErr := database.GetChannelKey(ctx, chID)
|
||||||
|
if keyErr == nil && key != "" && suppliedKey != key {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrBadChannelKey,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+k)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, limErr := database.GetChannelUserLimit(ctx, chID)
|
||||||
|
if limErr == nil && limit > 0 &&
|
||||||
|
memberCount >= int64(limit) {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrChannelIsFull,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+l)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
365
internal/service/service_test.go
Normal file
365
internal/service/service_test.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
// Tests use a global viper instance for configuration,
|
||||||
|
// making parallel execution unsafe.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest
|
||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"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.
|
||||||
|
type testEnv struct {
|
||||||
|
svc *service.Service
|
||||||
|
db *db.Database
|
||||||
|
broker *broker.Broker
|
||||||
|
app *fxtest.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestEnv(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbURL := fmt.Sprintf(
|
||||||
|
"file:svc_test_%p?mode=memory&cache=shared",
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
database *db.Database
|
||||||
|
svc *service.Service
|
||||||
|
)
|
||||||
|
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Provide(
|
||||||
|
func() *globals.Globals {
|
||||||
|
return &globals.Globals{ //nolint:exhaustruct
|
||||||
|
Appname: "neoirc-test",
|
||||||
|
Version: "test",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logger.New,
|
||||||
|
func(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
globs *globals.Globals,
|
||||||
|
log *logger.Logger,
|
||||||
|
) (*config.Config, error) {
|
||||||
|
cfg, err := config.New(
|
||||||
|
lifecycle, config.Params{ //nolint:exhaustruct
|
||||||
|
Globals: globs, Logger: log,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"test config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DBURL = dbURL
|
||||||
|
cfg.Port = 0
|
||||||
|
cfg.OperName = "admin"
|
||||||
|
cfg.OperPassword = "secret"
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
func(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
log *logger.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
) (*db.Database, error) {
|
||||||
|
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
||||||
|
Logger: log, Config: cfg,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func() *broker.Broker { return brk },
|
||||||
|
service.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&database, &svc),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
app.RequireStop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
svc: svc,
|
||||||
|
db: database,
|
||||||
|
broker: brk,
|
||||||
|
app: app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSession is a test helper that creates a session
|
||||||
|
// and returns the session ID.
|
||||||
|
func createSession(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
nick string,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.CreateSession(
|
||||||
|
ctx, nick, nick, "localhost", "127.0.0.1",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session %s: %v", nick, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFanOut(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
dbID, uuid, err := env.svc.FanOut(
|
||||||
|
ctx, irc.CmdPrivmsg, "alice", "#test",
|
||||||
|
nil, body, nil,
|
||||||
|
[]int64{sid1, sid2},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FanOut: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbID == 0 {
|
||||||
|
t.Error("expected non-zero dbID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uuid == "" {
|
||||||
|
t.Error("expected non-empty UUID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinChannel(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
result, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid, "alice", "#general", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ChannelID == 0 {
|
||||||
|
t.Error("expected non-zero channel ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.IsCreator {
|
||||||
|
t.Error("first joiner should be creator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second user joins — not creator.
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
result2, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#general", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinChannel bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result2.IsCreator {
|
||||||
|
t.Error("second joiner should not be creator")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result2.ChannelID != result.ChannelID {
|
||||||
|
t.Error("both should join the same channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartChannel(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
_, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid, "alice", "#general", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.PartChannel(
|
||||||
|
ctx, sid, "alice", "#general", "bye",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PartChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parting a non-existent channel returns error.
|
||||||
|
err = env.svc.PartChannel(
|
||||||
|
ctx, sid, "alice", "#nonexistent", "",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ircErr *service.IRCError
|
||||||
|
if !errors.As(err, &ircErr) {
|
||||||
|
t.Errorf("expected IRCError, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendChannelMessage(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
_, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid1, "alice", "#chat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#chat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
dbID, uuid, err := env.svc.SendChannelMessage(
|
||||||
|
ctx, sid1, "alice",
|
||||||
|
irc.CmdPrivmsg, "#chat", body, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendChannelMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbID == 0 {
|
||||||
|
t.Error("expected non-zero dbID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uuid == "" {
|
||||||
|
t.Error("expected non-empty UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-member cannot send.
|
||||||
|
sid3 := createSession(ctx, t, env.db, "charlie")
|
||||||
|
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, sid3, "charlie",
|
||||||
|
irc.CmdPrivmsg, "#chat", body, nil,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-member send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastQuit(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
_, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid1, "alice", "#room", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#room", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastQuit should not panic and should clean up.
|
||||||
|
env.svc.BroadcastQuit(
|
||||||
|
ctx, sid1, "alice", "Goodbye",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session should be deleted.
|
||||||
|
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
|
||||||
|
if lookupErr == nil {
|
||||||
|
t.Error("expected session to be deleted after quit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
result, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid1, "alice", "#modchat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#modchat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set channel to moderated.
|
||||||
|
chID := result.ChannelID
|
||||||
|
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
// Bob (non-op, non-voiced) should fail to send.
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, sid2, "bob",
|
||||||
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-voiced user in moderated channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice (operator) should succeed.
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, sid1, "alice",
|
||||||
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package stats_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
|
|||||||
@@ -2,26 +2,33 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAway = "AWAY"
|
CmdAdmin = "ADMIN"
|
||||||
CmdInvite = "INVITE"
|
CmdAway = "AWAY"
|
||||||
CmdJoin = "JOIN"
|
CmdInfo = "INFO"
|
||||||
CmdKick = "KICK"
|
CmdInvite = "INVITE"
|
||||||
CmdList = "LIST"
|
CmdJoin = "JOIN"
|
||||||
CmdLusers = "LUSERS"
|
CmdKick = "KICK"
|
||||||
CmdMode = "MODE"
|
CmdKill = "KILL"
|
||||||
CmdMotd = "MOTD"
|
CmdList = "LIST"
|
||||||
CmdNames = "NAMES"
|
CmdLusers = "LUSERS"
|
||||||
CmdNick = "NICK"
|
CmdMode = "MODE"
|
||||||
CmdNotice = "NOTICE"
|
CmdMotd = "MOTD"
|
||||||
CmdOper = "OPER"
|
CmdNames = "NAMES"
|
||||||
CmdPass = "PASS"
|
CmdNick = "NICK"
|
||||||
CmdPart = "PART"
|
CmdNotice = "NOTICE"
|
||||||
CmdPing = "PING"
|
CmdOper = "OPER"
|
||||||
CmdPong = "PONG"
|
CmdPass = "PASS"
|
||||||
CmdPrivmsg = "PRIVMSG"
|
CmdPart = "PART"
|
||||||
CmdQuit = "QUIT"
|
CmdPing = "PING"
|
||||||
CmdTopic = "TOPIC"
|
CmdPong = "PONG"
|
||||||
CmdUser = "USER"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdWho = "WHO"
|
CmdQuit = "QUIT"
|
||||||
CmdWhois = "WHOIS"
|
CmdTime = "TIME"
|
||||||
|
CmdTopic = "TOPIC"
|
||||||
|
CmdUser = "USER"
|
||||||
|
CmdUserhost = "USERHOST"
|
||||||
|
CmdVersion = "VERSION"
|
||||||
|
CmdWallops = "WALLOPS"
|
||||||
|
CmdWho = "WHO"
|
||||||
|
CmdWhois = "WHOIS"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
func TestName(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
|
||||||
"title": "JOIN",
|
"title": "JOIN",
|
||||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
|
||||||
"title": "KICK",
|
"title": "KICK",
|
||||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
|
||||||
"title": "MODE",
|
"title": "MODE",
|
||||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
|
||||||
"title": "NICK",
|
"title": "NICK",
|
||||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
|
||||||
"title": "NOTICE",
|
"title": "NOTICE",
|
||||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
|
||||||
"title": "PART",
|
"title": "PART",
|
||||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
|
||||||
"title": "PING",
|
"title": "PING",
|
||||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
|
||||||
"title": "PONG",
|
"title": "PONG",
|
||||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
|
||||||
"title": "PRIVMSG",
|
"title": "PRIVMSG",
|
||||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
|
||||||
"title": "PUBKEY",
|
"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.",
|
"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",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
|
||||||
"title": "QUIT",
|
"title": "QUIT",
|
||||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
|
||||||
"title": "TOPIC",
|
"title": "TOPIC",
|
||||||
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. 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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
|
||||||
"title": "IRC Message Envelope",
|
"title": "IRC Message Envelope",
|
||||||
"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.).",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
|
||||||
"title": "001 RPL_WELCOME",
|
"title": "001 RPL_WELCOME",
|
||||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
|
||||||
"title": "002 RPL_YOURHOST",
|
"title": "002 RPL_YOURHOST",
|
||||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
|
||||||
"title": "003 RPL_CREATED",
|
"title": "003 RPL_CREATED",
|
||||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
|
||||||
"title": "004 RPL_MYINFO",
|
"title": "004 RPL_MYINFO",
|
||||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
|
||||||
"title": "322 RPL_LIST",
|
"title": "322 RPL_LIST",
|
||||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
|
||||||
"title": "323 RPL_LISTEND",
|
"title": "323 RPL_LISTEND",
|
||||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
|
||||||
"title": "332 RPL_TOPIC",
|
"title": "332 RPL_TOPIC",
|
||||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
|
||||||
"title": "353 RPL_NAMREPLY",
|
"title": "353 RPL_NAMREPLY",
|
||||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
|
||||||
"title": "366 RPL_ENDOFNAMES",
|
"title": "366 RPL_ENDOFNAMES",
|
||||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
|
||||||
"title": "372 RPL_MOTD",
|
"title": "372 RPL_MOTD",
|
||||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
|
||||||
"title": "375 RPL_MOTDSTART",
|
"title": "375 RPL_MOTDSTART",
|
||||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
|
||||||
"title": "376 RPL_ENDOFMOTD",
|
"title": "376 RPL_ENDOFMOTD",
|
||||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/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 such nick/channel. RFC 1459 \u00a76.1.",
|
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/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 such channel. RFC 1459 \u00a76.1.",
|
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
|
||||||
"title": "433 ERR_NICKNAMEINUSE",
|
"title": "433 ERR_NICKNAMEINUSE",
|
||||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
|
||||||
"title": "442 ERR_NOTONCHANNEL",
|
"title": "442 ERR_NOTONCHANNEL",
|
||||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
|
||||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
Reference in New Issue
Block a user