Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7faabb68f | ||
|
|
b119b094f5 | ||
|
|
6acf2ca3b4 |
+8
-2
@@ -1,9 +1,15 @@
|
|||||||
# Web build stage — compile SPA from source
|
# Web build stage — compile SPA from source
|
||||||
# node:22-alpine, 2026-03-09
|
# node:22-alpine, 2026-03-09
|
||||||
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||||
|
# yarn 1.22.22, 2026-10-06
|
||||||
|
# The version script/bootstrap pins, checked against the sha512 of its
|
||||||
|
# npm package. corepack enable replaces the yarn the image ships with
|
||||||
|
# corepack's own, which runs the version activated here.
|
||||||
|
RUN corepack enable \
|
||||||
|
&& corepack prepare yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e --activate
|
||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/yarn.lock ./
|
||||||
RUN npm ci
|
RUN yarn install --frozen-lockfile
|
||||||
COPY web/src/ src/
|
COPY web/src/ src/
|
||||||
COPY web/build.sh build.sh
|
COPY web/build.sh build.sh
|
||||||
RUN sh build.sh
|
RUN sh build.sh
|
||||||
|
|||||||
@@ -1575,24 +1575,31 @@ reference with all required and optional fields.
|
|||||||
|
|
||||||
**Command dispatch table:**
|
**Command dispatch table:**
|
||||||
|
|
||||||
| Command | Required Fields | Optional | Response Status |
|
| Command | Required Fields | Optional | Response Status |
|
||||||
| --------- | --------------- | -------- | --------------- |
|
| ---------- | --------------- | -------- | --------------- |
|
||||||
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
||||||
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
||||||
| `JOIN` | `to` | | 200 OK |
|
| `JOIN` | `to` | | 200 OK |
|
||||||
| `PART` | `to` | `body` | 200 OK |
|
| `PART` | `to` | `body` | 200 OK |
|
||||||
| `NICK` | `body` | | 200 OK |
|
| `NICK` | `body` | | 200 OK |
|
||||||
| `PASS` | `body` | | 200 OK |
|
| `PASS` | `body` | | 200 OK |
|
||||||
| `TOPIC` | `to`, `body` | | 200 OK |
|
| `TOPIC` | `to`, `body` | | 200 OK |
|
||||||
| `MODE` | `to` | | 200 OK |
|
| `MODE` | `to` | `body` | 200 OK |
|
||||||
| `NAMES` | `to` | | 200 OK |
|
| `NAMES` | `to` | | 200 OK |
|
||||||
| `LIST` | | | 200 OK |
|
| `LIST` | | | 200 OK |
|
||||||
| `WHOIS` | `to` or `body` | | 200 OK |
|
| `WHOIS` | `to` or `body` | | 200 OK |
|
||||||
| `WHO` | `to` | | 200 OK |
|
| `WHO` | `to` | | 200 OK |
|
||||||
| `LUSERS` | | | 200 OK |
|
| `LUSERS` | | | 200 OK |
|
||||||
| `OPER` | `body` | | 200 OK |
|
| `USERHOST` | `body` | | 200 OK |
|
||||||
| `QUIT` | | `body` | 200 OK |
|
| `VERSION` | | | 200 OK |
|
||||||
| `PING` | | | 200 OK |
|
| `ADMIN` | | | 200 OK |
|
||||||
|
| `INFO` | | | 200 OK |
|
||||||
|
| `TIME` | | | 200 OK |
|
||||||
|
| `OPER` | `body` | | 200 OK |
|
||||||
|
| `KILL` | `body` | | 200 OK |
|
||||||
|
| `WALLOPS` | `body` | | 200 OK |
|
||||||
|
| `QUIT` | | `body` | 200 OK |
|
||||||
|
| `PING` | | | 200 OK |
|
||||||
|
|
||||||
All IRC commands return HTTP 200 OK. IRC-level success and error responses are
|
All IRC commands return HTTP 200 OK. IRC-level success and error responses are
|
||||||
delivered as **numeric replies** through the message queue (see
|
delivered as **numeric replies** through the message queue (see
|
||||||
@@ -1619,7 +1626,11 @@ auth cookies (401), and server errors (500).
|
|||||||
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
||||||
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
||||||
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
||||||
|
| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator |
|
||||||
|
| 483 | ERR_CANTKILLSERVER | KILL of yourself |
|
||||||
| 491 | ERR_NOOPERHOST | Failed OPER authentication |
|
| 491 | ERR_NOOPERHOST | Failed OPER authentication |
|
||||||
|
| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted |
|
||||||
|
| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own |
|
||||||
|
|
||||||
**IRC numeric success replies (delivered via message queue):**
|
**IRC numeric success replies (delivered via message queue):**
|
||||||
|
|
||||||
@@ -1630,11 +1641,13 @@ auth cookies (401), and server errors (500).
|
|||||||
| 003 | RPL_CREATED | Sent on session creation/login |
|
| 003 | RPL_CREATED | Sent on session creation/login |
|
||||||
| 004 | RPL_MYINFO | Sent on session creation/login |
|
| 004 | RPL_MYINFO | Sent on session creation/login |
|
||||||
| 005 | RPL_ISUPPORT | Sent on session creation/login |
|
| 005 | RPL_ISUPPORT | Sent on session creation/login |
|
||||||
| 221 | RPL_UMODEIS | In response to user MODE query |
|
| 221 | RPL_UMODEIS | In response to user MODE |
|
||||||
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
|
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
|
||||||
| 252 | RPL_LUSEROP | On connect or LUSERS command |
|
| 252 | RPL_LUSEROP | On connect or LUSERS command |
|
||||||
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
|
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
|
||||||
| 255 | RPL_LUSERME | On connect or LUSERS command |
|
| 255 | RPL_LUSERME | On connect or LUSERS command |
|
||||||
|
| 256–259 | RPL_ADMINME etc. | ADMIN info |
|
||||||
|
| 302 | RPL_USERHOST | USERHOST reply |
|
||||||
| 311 | RPL_WHOISUSER | WHOIS user info |
|
| 311 | RPL_WHOISUSER | WHOIS user info |
|
||||||
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
||||||
| 313 | RPL_WHOISOPERATOR | WHOIS target is oper |
|
| 313 | RPL_WHOISOPERATOR | WHOIS target is oper |
|
||||||
@@ -1642,6 +1655,10 @@ auth cookies (401), and server errors (500).
|
|||||||
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
||||||
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
||||||
| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) |
|
| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) |
|
||||||
|
| 351 | RPL_VERSION | VERSION reply |
|
||||||
|
| 371 | RPL_INFO | INFO line |
|
||||||
|
| 374 | RPL_ENDOFINFO | End of INFO |
|
||||||
|
| 391 | RPL_TIME | TIME reply |
|
||||||
| 322 | RPL_LIST | Channel in LIST response |
|
| 322 | RPL_LIST | Channel in LIST response |
|
||||||
| 323 | RPL_LISTEND | End of LIST |
|
| 323 | RPL_LISTEND | End of LIST |
|
||||||
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
|
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
|
||||||
@@ -2384,13 +2401,13 @@ IRC_LISTEN_ADDR=
|
|||||||
|
|
||||||
### Supported Commands
|
### Supported Commands
|
||||||
|
|
||||||
| Category | Commands |
|
| Category | Commands |
|
||||||
| ---------- | ------------------------------------------------------------------ |
|
| ---------- | ---------------------------------------------------------------------------------------- |
|
||||||
| 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
|
||||||
|
|
||||||
@@ -2406,6 +2423,15 @@ IRC_LISTEN_ADDR=
|
|||||||
operator status (`@`).
|
operator status (`@`).
|
||||||
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v`
|
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v`
|
||||||
(voice)
|
(voice)
|
||||||
|
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
|
||||||
|
`WALLOPS`). `MODE` for any nick other than your own is rejected with
|
||||||
|
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is
|
||||||
|
case-insensitive.
|
||||||
|
- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel
|
||||||
|
peers, deletes its session, and then sends the victim a `KILL` and
|
||||||
|
`ERROR :Closing Link` before closing its socket. This applies to victims on
|
||||||
|
the IRC listener regardless of whether the `KILL` arrived over IRC or the HTTP
|
||||||
|
API.
|
||||||
|
|
||||||
### Bridge to HTTP API
|
### Bridge to HTTP API
|
||||||
|
|
||||||
@@ -2946,6 +2972,10 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
from additional devices via `POST /api/v1/login`
|
from additional devices via `POST /api/v1/login`
|
||||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API
|
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API
|
||||||
authentication
|
authentication
|
||||||
|
- [x] **Tier 3 utility commands** — `USERHOST` (302), `VERSION` (351), `ADMIN`
|
||||||
|
(256–259), `INFO` (371/374), `TIME` (391), `KILL` (operator-only forced
|
||||||
|
disconnect), `WALLOPS` (operator-only broadcast to `+w` users)
|
||||||
|
- [x] **User mode +w** — receive `WALLOPS`, set with `MODE <nick> +w` / `-w`
|
||||||
|
|
||||||
### Future (1.0+)
|
### Future (1.0+)
|
||||||
|
|
||||||
@@ -3020,7 +3050,7 @@ neoirc/
|
|||||||
│ ├── embed.go # go:embed directive for SPA
|
│ ├── embed.go # go:embed directive for SPA
|
||||||
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||||
│ ├── package.json # Node dependencies (preact, esbuild)
|
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||||
│ ├── package-lock.json
|
│ ├── yarn.lock
|
||||||
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||||
│ │ ├── app.jsx
|
│ │ ├── app.jsx
|
||||||
│ │ ├── index.html
|
│ │ ├── index.html
|
||||||
|
|||||||
@@ -58,3 +58,17 @@ func (database *Database) Close() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecForTest runs a raw statement, such as a trigger that
|
||||||
|
// makes a write fail.
|
||||||
|
func (database *Database) ExecForTest(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exec for test: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -2423,3 +2424,172 @@ func (database *Database) SetChannelUserLimit(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionUserModes sets a session's wallops (+w) and
|
||||||
|
// oper (+o) flags in one transaction, so that either both
|
||||||
|
// changes are stored or neither is. A nil flag is left as
|
||||||
|
// it is.
|
||||||
|
func (database *Database) SetSessionUserModes(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
wallops *bool,
|
||||||
|
oper *bool,
|
||||||
|
) error {
|
||||||
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wallops != nil {
|
||||||
|
_, err = transaction.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||||
|
boolToInt(*wallops), sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return fmt.Errorf("set session wallops: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oper != nil {
|
||||||
|
_, err = transaction.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||||
|
boolToInt(*oper), sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return fmt.Errorf("set session oper: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = transaction.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("commit user modes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolToInt returns the 0 or 1 that the schema stores for a
|
||||||
|
// boolean.
|
||||||
|
func boolToInt(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
err = rows.Scan(&sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"scan wallops session: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if 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. Nicks with no session are left out.
|
||||||
|
func (database *Database) GetUserhostInfo(
|
||||||
|
ctx context.Context,
|
||||||
|
nicks []string,
|
||||||
|
) ([]UserhostInfo, error) {
|
||||||
|
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 errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"userhost lookup %q: %w", nick, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1491,3 +1491,119 @@ func TestChannelUserLimit(t *testing.T) {
|
|||||||
t.Fatalf("expected 0, got %d", limit)
|
t.Fatalf("expected 0, got %d", limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetSessionUserModesIsAtomic makes the is_oper write
|
||||||
|
// fail after the is_wallops write has run, and checks that
|
||||||
|
// the wallops change was rolled back.
|
||||||
|
func TestSetSessionUserModesIsAtomic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.CreateSession(
|
||||||
|
ctx, "alice", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = database.ExecForTest(ctx,
|
||||||
|
`CREATE TRIGGER reject_oper
|
||||||
|
BEFORE UPDATE OF is_oper ON sessions
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
|
||||||
|
END`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wallops := true
|
||||||
|
oper := false
|
||||||
|
|
||||||
|
err = database.SetSessionUserModes(
|
||||||
|
ctx, sessionID, &wallops, &oper,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the rejected oper write to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err := database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotWallops {
|
||||||
|
t.Error("wallops was stored although the change failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetSessionUserModesAppliesBoth checks that both flags
|
||||||
|
// are written, and that a nil flag is left as it is.
|
||||||
|
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.CreateSession(
|
||||||
|
ctx, "alice", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = database.SetSessionOper(ctx, sessionID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wallops := true
|
||||||
|
oper := false
|
||||||
|
|
||||||
|
err = database.SetSessionUserModes(
|
||||||
|
ctx, sessionID, &wallops, &oper,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err := database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotOper, err := database.IsSessionOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotWallops || gotOper {
|
||||||
|
t.Errorf(
|
||||||
|
"want wallops=true oper=false, got %v/%v",
|
||||||
|
gotWallops, gotOper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = database.SetSessionUserModes(
|
||||||
|
ctx, sessionID, nil, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err = database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotWallops {
|
||||||
|
t.Error("a nil wallops flag cleared wallops")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
+28
-11
@@ -1015,10 +1015,10 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case irc.CmdOper:
|
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
|
||||||
hdlr.handleOper(
|
hdlr.dispatchOperCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick, command, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdMotd, irc.CmdPing:
|
case irc.CmdMotd, irc.CmdPing:
|
||||||
hdlr.dispatchInfoCommand(
|
hdlr.dispatchInfoCommand(
|
||||||
@@ -1075,6 +1075,27 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
)
|
)
|
||||||
|
case irc.CmdUserhost:
|
||||||
|
hdlr.handleUserhost(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdVersion:
|
||||||
|
hdlr.handleVersion(
|
||||||
|
writer, request, sessionID, clientID, nick,
|
||||||
|
)
|
||||||
|
case irc.CmdAdmin:
|
||||||
|
hdlr.handleAdmin(
|
||||||
|
writer, request, sessionID, clientID, nick,
|
||||||
|
)
|
||||||
|
case irc.CmdInfo:
|
||||||
|
hdlr.handleInfo(
|
||||||
|
writer, request, sessionID, clientID, nick,
|
||||||
|
)
|
||||||
|
case irc.CmdTime:
|
||||||
|
hdlr.handleTime(
|
||||||
|
writer, request, sessionID, clientID, nick,
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
@@ -1951,15 +1972,11 @@ func (hdlr *Handlers) handleMode(
|
|||||||
|
|
||||||
channel := target
|
channel := target
|
||||||
if !strings.HasPrefix(channel, "#") {
|
if !strings.HasPrefix(channel, "#") {
|
||||||
// User mode query — return empty modes.
|
hdlr.handleUserMode(
|
||||||
hdlr.enqueueNumeric(
|
writer, request,
|
||||||
request.Context(), clientID,
|
sessionID, clientID, nick, target,
|
||||||
irc.RplUmodeIs, nick, nil, "+",
|
bodyLines,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{statusKey: "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ func newTestHandlers(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Database: database,
|
Database: database,
|
||||||
Broker: brk,
|
Broker: brk,
|
||||||
|
Globals: globs,
|
||||||
})
|
})
|
||||||
|
|
||||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dispatchOperCommand routes the server operator commands
|
||||||
|
// OPER, KILL and 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 USERHOST: user@host for each of
|
||||||
|
// up to five nicks given in the body.
|
||||||
|
func (hdlr *Handlers) handleUserhost(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdUserhost},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := hdlr.svc.UserhostReply(
|
||||||
|
request.Context(), lines, hdlr.serverName(),
|
||||||
|
)
|
||||||
|
if hdlr.handleServiceError(
|
||||||
|
writer, request, clientID, sessionID, nick, err,
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID,
|
||||||
|
irc.RplUserHost, nick, nil, reply,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVersion handles VERSION.
|
||||||
|
func (hdlr *Handlers) handleVersion(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID, irc.RplVersion, nick,
|
||||||
|
[]string{
|
||||||
|
hdlr.svc.ServerVersion() + ".",
|
||||||
|
hdlr.serverName(),
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdmin handles ADMIN.
|
||||||
|
func (hdlr *Handlers) handleAdmin(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminMe, nick,
|
||||||
|
[]string{srvName}, "Administrative info",
|
||||||
|
)
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
||||||
|
"neoirc server",
|
||||||
|
)
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
||||||
|
"IRC over HTTP",
|
||||||
|
)
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
||||||
|
"admin@"+srvName,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo handles INFO.
|
||||||
|
func (hdlr *Handlers) handleInfo(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
for _, line := range hdlr.svc.InfoLines() {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplInfo, nick, nil, line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
||||||
|
"End of /INFO list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime handles TIME: the server's local time.
|
||||||
|
func (hdlr *Handlers) handleTime(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID, irc.RplTime, nick,
|
||||||
|
[]string{hdlr.serverName()},
|
||||||
|
time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKill handles KILL: the body is the target nick and
|
||||||
|
// an optional reason.
|
||||||
|
func (hdlr *Handlers) handleKill(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
lines := bodyLines()
|
||||||
|
|
||||||
|
targetNick := ""
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.svc.KillUser(
|
||||||
|
request.Context(), sessionID, nick,
|
||||||
|
targetNick, reason,
|
||||||
|
)
|
||||||
|
if hdlr.handleServiceError(
|
||||||
|
writer, request, clientID, sessionID, nick, err,
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWallops handles WALLOPS: the body is the message.
|
||||||
|
func (hdlr *Handlers) handleWallops(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdWallops},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.svc.SendWallops(
|
||||||
|
request.Context(), sessionID, nick,
|
||||||
|
strings.Join(lines, " "),
|
||||||
|
)
|
||||||
|
if hdlr.handleServiceError(
|
||||||
|
writer, request, clientID, sessionID, nick, err,
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserMode handles MODE for a nick: a query when the
|
||||||
|
// body is empty, otherwise a change by the mode string in
|
||||||
|
// the body. Only the caller's own nick, in any letter case,
|
||||||
|
// is allowed.
|
||||||
|
func (hdlr *Handlers) handleUserMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, target string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
if !strings.EqualFold(target, nick) {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUsersDoNotMatch, nick, nil,
|
||||||
|
"Can't change mode for other users",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
var (
|
||||||
|
modes string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) > 0 {
|
||||||
|
modes, err = hdlr.svc.ApplyUserMode(
|
||||||
|
ctx, sessionID, lines[0],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
modes, err = hdlr.svc.QueryUserMode(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hdlr.handleServiceError(
|
||||||
|
writer, request, clientID, sessionID, nick, err,
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUmodeIs, nick, nil, modes,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{statusKey: "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
//nolint:paralleltest // global viper, as in api_test.go
|
||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each test starts one server and reuses it for all its
|
||||||
|
// checks, and makes as few requests as it can: servers,
|
||||||
|
// sessions and requests are what makes this package's tests
|
||||||
|
// slow.
|
||||||
|
|
||||||
|
// sendAndPoll sends cmd from the session token and returns
|
||||||
|
// the messages after lastID that the session has been sent,
|
||||||
|
// and the ID of the last of them.
|
||||||
|
func sendAndPoll(
|
||||||
|
tserver *testServer,
|
||||||
|
token string,
|
||||||
|
lastID int64,
|
||||||
|
cmd map[string]any,
|
||||||
|
) ([]map[string]any, int64) {
|
||||||
|
tserver.t.Helper()
|
||||||
|
|
||||||
|
tserver.sendCommand(token, cmd)
|
||||||
|
|
||||||
|
return tserver.pollMessages(token, lastID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOperSession creates a session for nick, makes it a
|
||||||
|
// server operator, and returns its token and the ID of the
|
||||||
|
// last message it has been sent.
|
||||||
|
func newOperSession(
|
||||||
|
tserver *testServer, nick string,
|
||||||
|
) (string, int64) {
|
||||||
|
tserver.t.Helper()
|
||||||
|
|
||||||
|
token := tserver.createSession(nick)
|
||||||
|
|
||||||
|
_, lastID := sendAndPoll(tserver, token, 0, map[string]any{
|
||||||
|
commandKey: irc.CmdOper,
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
return token, lastID
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericBody returns the first body line of the message in
|
||||||
|
// msgs with the given numeric, and fails the test if there
|
||||||
|
// is none.
|
||||||
|
func numericBody(
|
||||||
|
t *testing.T, msgs []map[string]any, numeric string,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, numeric)
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf("expected numeric %s, got %v", numeric, msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, _ := msg[bodyKey].([]any)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
line, _ := lines[0].(string)
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhost(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("alice")
|
||||||
|
tserver.createSession("bob")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
|
||||||
|
commandKey: irc.CmdUserhost,
|
||||||
|
bodyKey: []string{"alice", "bob"},
|
||||||
|
})
|
||||||
|
|
||||||
|
body := numericBody(t, msgs, "302")
|
||||||
|
if !strings.HasPrefix(body, "alice=+alice@") ||
|
||||||
|
!strings.Contains(body, " bob=+bob@") {
|
||||||
|
t.Errorf("expected alice and bob, got %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{
|
||||||
|
commandKey: irc.CmdUserhost,
|
||||||
|
})
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionAdminInfoTime(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("frank")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
|
||||||
|
commandKey: irc.CmdVersion,
|
||||||
|
})
|
||||||
|
|
||||||
|
params := getNumericParams(findNumericWithParams(msgs, "351"))
|
||||||
|
if len(params) == 0 || params[0] != "neoirc-test-test." {
|
||||||
|
t.Errorf("expected RPL_VERSION neoirc-test-test., got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, check := range []struct {
|
||||||
|
command string
|
||||||
|
numerics []string
|
||||||
|
}{
|
||||||
|
{irc.CmdAdmin, []string{"256", "257", "258", "259"}},
|
||||||
|
{irc.CmdInfo, []string{"371", "374"}},
|
||||||
|
{irc.CmdTime, []string{"391"}},
|
||||||
|
} {
|
||||||
|
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
|
||||||
|
commandKey: check.command,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, numeric := range check.numerics {
|
||||||
|
if !findNumeric(msgs, numeric) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: expected %s, got %v",
|
||||||
|
check.command, numeric, msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKill(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
victimToken := tserver.createSession("victim")
|
||||||
|
observerToken := tserver.createSession("observer")
|
||||||
|
notOperToken := tserver.createSession("notoper")
|
||||||
|
operToken, operLastID := newOperSession(tserver, "killer")
|
||||||
|
|
||||||
|
for _, token := range []string{victimToken, observerToken} {
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd,
|
||||||
|
toKey: "#killtest",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, notOperLastID := tserver.pollMessages(notOperToken, 0)
|
||||||
|
|
||||||
|
msgs, _ := sendAndPoll(tserver, notOperToken, notOperLastID, map[string]any{
|
||||||
|
commandKey: irc.CmdKill,
|
||||||
|
bodyKey: []string{"victim"},
|
||||||
|
})
|
||||||
|
if !findNumeric(msgs, "481") {
|
||||||
|
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, check := range []struct {
|
||||||
|
numeric string
|
||||||
|
body []string
|
||||||
|
}{
|
||||||
|
{"461", []string{}},
|
||||||
|
{"401", []string{"ghost"}},
|
||||||
|
{"483", []string{"killer"}},
|
||||||
|
} {
|
||||||
|
msgs, operLastID = sendAndPoll(tserver, operToken, operLastID, map[string]any{
|
||||||
|
commandKey: irc.CmdKill,
|
||||||
|
bodyKey: check.body,
|
||||||
|
})
|
||||||
|
if !findNumeric(msgs, check.numeric) {
|
||||||
|
t.Errorf(
|
||||||
|
"KILL %v: expected %s, got %v",
|
||||||
|
check.body, check.numeric, msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, observerLastID := tserver.pollMessages(observerToken, 0)
|
||||||
|
|
||||||
|
status, result := tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: irc.CmdKill,
|
||||||
|
bodyKey: []string{"victim", "go away"},
|
||||||
|
})
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %v", status, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(observerToken, observerLastID)
|
||||||
|
if !findMessage(msgs, irc.CmdQuit, "victim") {
|
||||||
|
t.Errorf("expected the observer to see QUIT, got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _ = tserver.getState(victimToken)
|
||||||
|
if status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected the victim's session gone, got %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWallops(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
receiverToken := tserver.createSession("receiver")
|
||||||
|
plainToken := tserver.createSession("plain")
|
||||||
|
operToken, operLastID := newOperSession(tserver, "walloper")
|
||||||
|
|
||||||
|
_, receiverLastID := sendAndPoll(tserver, receiverToken, 0, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "receiver",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
_, plainLastID := tserver.pollMessages(plainToken, 0)
|
||||||
|
|
||||||
|
msgs, plainLastID := sendAndPoll(tserver, plainToken, plainLastID, map[string]any{
|
||||||
|
commandKey: irc.CmdWallops,
|
||||||
|
bodyKey: []string{"not allowed"},
|
||||||
|
})
|
||||||
|
if !findNumeric(msgs, "481") {
|
||||||
|
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ = sendAndPoll(tserver, operToken, operLastID, map[string]any{
|
||||||
|
commandKey: irc.CmdWallops,
|
||||||
|
})
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: irc.CmdWallops,
|
||||||
|
bodyKey: []string{"server going down"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(receiverToken, receiverLastID)
|
||||||
|
if !findMessage(msgs, irc.CmdWallops, "walloper") {
|
||||||
|
t.Errorf("expected WALLOPS for the +w user, got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(plainToken, plainLastID)
|
||||||
|
if findMessage(msgs, irc.CmdWallops, "walloper") {
|
||||||
|
t.Errorf("WALLOPS reached a user without +w: %v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserMode(t *testing.T) {
|
||||||
|
const (
|
||||||
|
nick = "alice"
|
||||||
|
other = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession(nick)
|
||||||
|
otherToken := tserver.createSession(other)
|
||||||
|
operToken, operLastID := newOperSession(tserver, "deoper")
|
||||||
|
|
||||||
|
// mode sends MODE for target from token, with modeStr
|
||||||
|
// unless it is empty, and returns the session's next
|
||||||
|
// messages.
|
||||||
|
mode := func(
|
||||||
|
token string, lastID int64, target, modeStr string,
|
||||||
|
) ([]map[string]any, int64) {
|
||||||
|
cmd := map[string]any{commandKey: modeCmd, toKey: target}
|
||||||
|
if modeStr != "" {
|
||||||
|
cmd[bodyKey] = []string{modeStr}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendAndPoll(tserver, token, lastID, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, otherLastID := mode(otherToken, 0, other, "+w")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
for _, check := range []struct {
|
||||||
|
target, modeStr, numeric, modes string
|
||||||
|
}{
|
||||||
|
{nick, "+w", "221", "+w"},
|
||||||
|
{nick, "", "221", "+w"},
|
||||||
|
{strings.ToUpper(nick), "-w", "221", "+"},
|
||||||
|
{nick, "+z", "501", ""},
|
||||||
|
{other, "", "502", ""},
|
||||||
|
{other, "-w", "502", ""},
|
||||||
|
} {
|
||||||
|
var msgs []map[string]any
|
||||||
|
|
||||||
|
msgs, lastID = mode(token, lastID, check.target, check.modeStr)
|
||||||
|
|
||||||
|
if check.numeric != "221" {
|
||||||
|
if !findNumeric(msgs, check.numeric) ||
|
||||||
|
findNumeric(msgs, "221") {
|
||||||
|
t.Errorf(
|
||||||
|
"MODE %s %s: expected only %s, got %v",
|
||||||
|
check.target, check.modeStr,
|
||||||
|
check.numeric, msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
got := numericBody(t, msgs, "221")
|
||||||
|
if got != check.modes {
|
||||||
|
t.Errorf(
|
||||||
|
"MODE %s %s: expected %q, got %q",
|
||||||
|
check.target, check.modeStr, check.modes, got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := mode(otherToken, otherLastID, other, "")
|
||||||
|
if got := numericBody(t, msgs, "221"); got != "+w" {
|
||||||
|
t.Errorf("%s's modes changed to %q", other, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ = mode(operToken, operLastID, "deoper", "-o")
|
||||||
|
if got := numericBody(t, msgs, "221"); got != "+" {
|
||||||
|
t.Errorf("after -o: expected +, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-39
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 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, and logs any other error.
|
||||||
func (c *Conn) sendIRCError(err error) {
|
func (c *Conn) sendIRCError(err error) {
|
||||||
var ircErr *service.IRCError
|
var ircErr *service.IRCError
|
||||||
if errors.As(err, &ircErr) {
|
if errors.As(err, &ircErr) {
|
||||||
@@ -21,7 +21,11 @@ func (c *Conn) sendIRCError(err error) {
|
|||||||
args = append(args, ircErr.Params...)
|
args = append(args, ircErr.Params...)
|
||||||
args = append(args, ircErr.Message)
|
args = append(args, ircErr.Message)
|
||||||
c.sendNumeric(ircErr.Code, args...)
|
c.sendNumeric(ircErr.Code, args...)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.log.Error("command failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCAP silently acknowledges CAP negotiation.
|
// handleCAP silently acknowledges CAP negotiation.
|
||||||
@@ -345,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) {
|
|||||||
|
|
||||||
c.send("ERROR :Closing Link: " + c.hostname +
|
c.send("ERROR :Closing Link: " + c.hostname +
|
||||||
" (Quit: " + reason + ")")
|
" (Quit: " + reason + ")")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTopic gets or sets a channel topic via the shared
|
// handleTopic gets or sets a channel topic via the shared
|
||||||
@@ -427,7 +434,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,11 +693,14 @@ func (c *Conn) applyChannelModes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUserMode handles MODE for users.
|
// handleUserMode handles MODE for a nick: a query without a
|
||||||
func (c *Conn) handleUserMode(msg *Message) {
|
// mode string, otherwise a change. Only the client's own
|
||||||
target := msg.Params[0]
|
// nick, in any letter case, is allowed.
|
||||||
|
func (c *Conn) handleUserMode(
|
||||||
if !strings.EqualFold(target, c.nick) {
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
if !strings.EqualFold(msg.Params[0], c.currentNick()) {
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrUsersDoNotMatch,
|
irc.ErrUsersDoNotMatch,
|
||||||
"Can't change mode for other users",
|
"Can't change mode for other users",
|
||||||
@@ -699,8 +709,26 @@ func (c *Conn) handleUserMode(msg *Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't support user modes beyond the basics.
|
var (
|
||||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
modes string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(msg.Params) == 1 {
|
||||||
|
modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
|
||||||
|
} else {
|
||||||
|
modes, err = c.svc.ApplyUserMode(
|
||||||
|
ctx, c.sessionID, msg.Params[1],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.sendIRCError(err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplUmodeIs, modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNames replies with channel member list.
|
// handleNames replies with channel member list.
|
||||||
@@ -1248,47 +1276,114 @@ func (c *Conn) handleInvite(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUserhost replies with USERHOST info.
|
// handleUserhost replies with user@host for each of up to
|
||||||
|
// five nicks.
|
||||||
func (c *Conn) handleUserhost(
|
func (c *Conn) handleUserhost(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg *Message,
|
msg *Message,
|
||||||
) {
|
) {
|
||||||
if len(msg.Params) < 1 {
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
irc.CmdUserhost, "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
replies := make([]string, 0, len(msg.Params))
|
reply, err := c.svc.UserhostReply(
|
||||||
|
ctx, msg.Params, c.serverSfx,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.sendIRCError(err)
|
||||||
|
|
||||||
for _, nick := range msg.Params {
|
return
|
||||||
sid, err := c.database.GetSessionByNick(ctx, nick)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
hostInfo, _ := c.database.GetSessionHostInfo(
|
|
||||||
ctx, sid,
|
|
||||||
)
|
|
||||||
|
|
||||||
host := "*"
|
|
||||||
if hostInfo != nil {
|
|
||||||
host = hostInfo.Hostname
|
|
||||||
}
|
|
||||||
|
|
||||||
isOper, _ := c.database.IsSessionOper(ctx, sid)
|
|
||||||
|
|
||||||
operStar := ""
|
|
||||||
if isOper {
|
|
||||||
operStar = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
replies = append(
|
|
||||||
replies,
|
|
||||||
nick+operStar+"=+"+nick+"@"+host,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplUserHost, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVersion replies with the server version.
|
||||||
|
func (c *Conn) handleVersion() {
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.RplUserHost,
|
irc.RplVersion,
|
||||||
strings.Join(replies, " "),
|
c.svc.ServerVersion()+".", c.serverSfx, "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdmin replies with the server's admin info.
|
||||||
|
func (c *Conn) handleAdmin() {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminMe, c.serverSfx, "Administrative info",
|
||||||
|
)
|
||||||
|
c.sendNumeric(irc.RplAdminLoc1, "neoirc server")
|
||||||
|
c.sendNumeric(irc.RplAdminLoc2, "IRC over HTTP")
|
||||||
|
c.sendNumeric(irc.RplAdminEmail, "admin@"+c.serverSfx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo replies with the server's software info.
|
||||||
|
func (c *Conn) handleInfo() {
|
||||||
|
for _, line := range c.svc.InfoLines() {
|
||||||
|
c.sendNumeric(irc.RplInfo, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplEndOfInfo, "End of /INFO list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime replies with the server's local time.
|
||||||
|
func (c *Conn) handleTime() {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplTime,
|
||||||
|
c.serverSfx, time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKill handles KILL <nick> [<reason>].
|
||||||
|
func (c *Conn) handleKill(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
irc.CmdKill, "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "KILLed"
|
||||||
|
if len(msg.Params) > 1 {
|
||||||
|
reason = msg.Params[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.svc.KillUser(
|
||||||
|
ctx, c.sessionID, c.currentNick(),
|
||||||
|
msg.Params[0], reason,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.sendIRCError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWallops handles WALLOPS <message>.
|
||||||
|
func (c *Conn) handleWallops(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
irc.CmdWallops, "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.svc.SendWallops(
|
||||||
|
ctx, c.sessionID, c.currentNick(), msg.Params[0],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.sendIRCError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+112
-6
@@ -22,6 +22,7 @@ const (
|
|||||||
maxLineLen = 512
|
maxLineLen = 512
|
||||||
readTimeout = 5 * time.Minute
|
readTimeout = 5 * time.Minute
|
||||||
writeTimeout = 30 * time.Second
|
writeTimeout = 30 * time.Second
|
||||||
|
killWriteWindow = 2 * 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
|
||||||
@@ -46,6 +47,11 @@ type Conn struct {
|
|||||||
serverSfx string
|
serverSfx string
|
||||||
commands map[string]cmdHandler
|
commands map[string]cmdHandler
|
||||||
|
|
||||||
|
// writeMu serializes writes to conn, which come from
|
||||||
|
// serve(), from the relay goroutine and, for KILL, from
|
||||||
|
// Disconnect.
|
||||||
|
writeMu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nick string
|
nick string
|
||||||
username string
|
username string
|
||||||
@@ -62,6 +68,7 @@ type Conn struct {
|
|||||||
|
|
||||||
lastQueueID int64
|
lastQueueID int64
|
||||||
closed bool
|
closed bool
|
||||||
|
killed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConn(
|
func newConn(
|
||||||
@@ -97,6 +104,57 @@ func newConn(
|
|||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect ends the connection for an operator KILL from
|
||||||
|
// either transport: the victim is sent KILL and ERROR, then
|
||||||
|
// its socket is closed, which ends serve() and with it the
|
||||||
|
// relay goroutine. It is called from the killer's goroutine
|
||||||
|
// and does not wait for the writes, so a victim that has
|
||||||
|
// stopped reading cannot stall the killer.
|
||||||
|
func (c *Conn) Disconnect(reason string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.closed = true
|
||||||
|
c.killed = true
|
||||||
|
nick := c.nick
|
||||||
|
host := c.hostname
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if nick == "" {
|
||||||
|
nick = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
c.notifyKilledAndClose(nick, host, reason)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyKilledAndClose sends a killed victim the KILL and
|
||||||
|
// ERROR lines, each bounded by killWriteWindow, and then
|
||||||
|
// closes its socket.
|
||||||
|
func (c *Conn) notifyKilledAndClose(
|
||||||
|
nick, host, reason string,
|
||||||
|
) {
|
||||||
|
defer func() { _ = c.conn.Close() }()
|
||||||
|
|
||||||
|
c.sendWithin(
|
||||||
|
killWriteWindow,
|
||||||
|
FormatMessage(
|
||||||
|
c.serverSfx, irc.CmdKill, nick, reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
c.sendWithin(
|
||||||
|
killWriteWindow,
|
||||||
|
"ERROR :Closing Link: "+host+
|
||||||
|
" ("+reason+")",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// buildCommandMap returns a map from IRC command strings
|
// buildCommandMap returns a map from IRC command strings
|
||||||
// to handler functions.
|
// to handler functions.
|
||||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||||
@@ -129,7 +187,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,
|
irc.CmdUserhost: c.handleUserhost,
|
||||||
|
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
|
||||||
|
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
|
||||||
|
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
|
||||||
|
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
|
||||||
|
irc.CmdKill: c.handleKill,
|
||||||
|
irc.CmdWallops: c.handleWallops,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +244,7 @@ func (c *Conn) serve(ctx context.Context) {
|
|||||||
|
|
||||||
c.handleMessage(ctx, msg)
|
c.handleMessage(ctx, msg)
|
||||||
|
|
||||||
if c.closed {
|
if c.isClosed() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,24 +253,60 @@ func (c *Conn) serve(ctx context.Context) {
|
|||||||
func (c *Conn) cleanup(ctx context.Context) {
|
func (c *Conn) cleanup(ctx context.Context) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
wasRegistered := c.registered
|
wasRegistered := c.registered
|
||||||
|
wasKilled := c.killed
|
||||||
sessID := c.sessionID
|
sessID := c.sessionID
|
||||||
nick := c.nick
|
nick := c.nick
|
||||||
c.closed = true
|
c.closed = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if wasRegistered && sessID > 0 {
|
if wasRegistered && sessID > 0 {
|
||||||
c.svc.BroadcastQuit(
|
c.svc.UnregisterWireConn(sessID, c)
|
||||||
ctx, sessID, nick, "Connection closed",
|
|
||||||
)
|
// KILL has already sent the QUIT and deleted the
|
||||||
|
// session.
|
||||||
|
if !wasKilled {
|
||||||
|
c.svc.BroadcastQuit(
|
||||||
|
ctx, sessID, nick, "Connection closed",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.conn.Close() //nolint:errcheck,gosec
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isClosed reports whether QUIT or KILL has ended the
|
||||||
|
// connection.
|
||||||
|
func (c *Conn) isClosed() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentNick returns c.nick, read under c.mu.
|
||||||
|
func (c *Conn) currentNick() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.nick
|
||||||
|
}
|
||||||
|
|
||||||
// send writes a formatted IRC line to the connection.
|
// send writes a formatted IRC line to the connection.
|
||||||
func (c *Conn) send(line string) {
|
func (c *Conn) send(line string) {
|
||||||
|
c.sendWithin(writeTimeout, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWithin writes a formatted IRC line to the connection,
|
||||||
|
// giving up after timeout.
|
||||||
|
func (c *Conn) sendWithin(
|
||||||
|
timeout time.Duration,
|
||||||
|
line string,
|
||||||
|
) {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
_ = c.conn.SetWriteDeadline(
|
_ = c.conn.SetWriteDeadline(
|
||||||
time.Now().Add(writeTimeout),
|
time.Now().Add(timeout),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||||
@@ -387,7 +487,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
|||||||
"failed to create session", "error", err,
|
"failed to create session", "error", err,
|
||||||
)
|
)
|
||||||
c.send("ERROR :Internal server error")
|
c.send("ERROR :Internal server error")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -398,6 +501,9 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
|||||||
c.registered = true
|
c.registered = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// So that KILL, from either transport, can close it.
|
||||||
|
c.svc.RegisterWireConn(sessionID, c)
|
||||||
|
|
||||||
// If PASS was provided before registration, set the
|
// If PASS was provided before registration, set the
|
||||||
// session password.
|
// session password.
|
||||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newKillVictim returns a Conn for the nick victim whose
|
||||||
|
// socket is one end of a net.Pipe, and the other end, from
|
||||||
|
// which the test reads what the victim is sent. A write to
|
||||||
|
// the pipe blocks until the other end reads.
|
||||||
|
func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
serverSide, clientSide := net.Pipe()
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = clientSide.Close() })
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError},
|
||||||
|
))
|
||||||
|
cfg := &config.Config{ServerName: testServerName}
|
||||||
|
|
||||||
|
return ircserver.NewTestConn(
|
||||||
|
log, cfg, serverSide, "victim",
|
||||||
|
), clientSide
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
|
||||||
|
// that Disconnect returns although the victim never reads,
|
||||||
|
// so a KILL cannot stall the operator who sent it.
|
||||||
|
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
victim, _ := newKillVictim(t)
|
||||||
|
returned := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
victim.Disconnect("killed by oper")
|
||||||
|
close(returned)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-returned:
|
||||||
|
case <-time.After(testTimeout):
|
||||||
|
t.Fatal("Disconnect waited for the victim to read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDisconnectNotifiesAndClosesVictim checks that a
|
||||||
|
// victim that reads is sent KILL and ERROR, and that its
|
||||||
|
// connection is then closed.
|
||||||
|
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
victim, clientSide := newKillVictim(t)
|
||||||
|
received := make(chan []string, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(clientSide)
|
||||||
|
for scanner.Scan() {
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
received <- lines
|
||||||
|
}()
|
||||||
|
|
||||||
|
victim.Disconnect("killed by oper")
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
select {
|
||||||
|
case lines = <-received:
|
||||||
|
case <-time.After(testTimeout):
|
||||||
|
t.Fatal("the victim's connection was not closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(lines, "\n")
|
||||||
|
|
||||||
|
if !strings.Contains(joined, "KILL victim :killed by oper") {
|
||||||
|
t.Errorf("missing KILL line, got: %q", joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(joined, "ERROR :Closing Link:") {
|
||||||
|
t.Errorf("missing ERROR line, got: %q", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,8 +21,14 @@ func NewTestServer(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
) *Server {
|
) *Server {
|
||||||
|
globs := &globals.Globals{
|
||||||
|
Appname: "neoirc",
|
||||||
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
svc := service.NewTestService(
|
svc := service.NewTestService(
|
||||||
database, brk, cfg, log,
|
database, brk, cfg, globs, log,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &Server{ //nolint:exhaustruct
|
return &Server{ //nolint:exhaustruct
|
||||||
@@ -47,3 +55,20 @@ func (s *Server) Stop() {
|
|||||||
func (s *Server) Listener() net.Listener {
|
func (s *Server) Listener() net.Listener {
|
||||||
return s.listener
|
return s.listener
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTestConn returns a Conn for nick on tcpConn, without a
|
||||||
|
// server, database or service behind it.
|
||||||
|
func NewTestConn(
|
||||||
|
log *slog.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
tcpConn net.Conn,
|
||||||
|
nick string,
|
||||||
|
) *Conn {
|
||||||
|
conn := newConn(
|
||||||
|
context.Background(), tcpConn, log,
|
||||||
|
nil, nil, cfg, nil,
|
||||||
|
)
|
||||||
|
conn.nick = nick
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIntegrationTwoClients is a comprehensive integration
|
// TestIntegrationTwoClients is a comprehensive integration
|
||||||
@@ -762,6 +764,371 @@ func TestIntegrationTwoClients(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tier 3 Utility Command Integration Tests ──────────
|
||||||
|
|
||||||
|
// TestIntegrationUserhost verifies the USERHOST command
|
||||||
|
// returns user@host info for connected nicks.
|
||||||
|
func TestIntegrationUserhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
bob.send("AWAY :lunch")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 306 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, step := range []struct {
|
||||||
|
line, want string
|
||||||
|
}{
|
||||||
|
{"USERHOST alice", " 302 alice alice=+alice@"},
|
||||||
|
{"USERHOST alice bob", " :alice=+alice@"},
|
||||||
|
{"USERHOST alice bob", " bob=-bob@"},
|
||||||
|
{"USERHOST nobody", " 302 alice :"},
|
||||||
|
{"USERHOST", " 461 alice USERHOST :"},
|
||||||
|
} {
|
||||||
|
alice.send(step.line)
|
||||||
|
|
||||||
|
reply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 302 ") ||
|
||||||
|
strings.Contains(l, " 461 ")
|
||||||
|
})
|
||||||
|
assertContains(t, reply, step.want, step.line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationVersion verifies the VERSION command
|
||||||
|
// returns the server version string.
|
||||||
|
func TestIntegrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("VERSION")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 351 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 351 ",
|
||||||
|
"RPL_VERSION",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "neoirc",
|
||||||
|
"VERSION reply contains server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationAdmin verifies the ADMIN command returns
|
||||||
|
// server admin info (256–259 numerics).
|
||||||
|
func TestIntegrationAdmin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("ADMIN")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 259 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 256 ",
|
||||||
|
"RPL_ADMINME",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 257 ",
|
||||||
|
"RPL_ADMINLOC1",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 258 ",
|
||||||
|
"RPL_ADMINLOC2",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 259 ",
|
||||||
|
"RPL_ADMINEMAIL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationInfo verifies the INFO command returns
|
||||||
|
// server information (371/374 numerics).
|
||||||
|
func TestIntegrationInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("INFO")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 374 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 371 ",
|
||||||
|
"RPL_INFO",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 374 ",
|
||||||
|
"RPL_ENDOFINFO",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "neoirc",
|
||||||
|
"INFO reply mentions server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationTime verifies the TIME command returns
|
||||||
|
// the server time (391 numeric).
|
||||||
|
func TestIntegrationTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("TIME")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 391 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 391 ",
|
||||||
|
"RPL_TIME",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, testServerName,
|
||||||
|
"TIME reply includes server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// becomeOper sends OPER with newTestEnvWithOper's
|
||||||
|
// credentials and waits for RPL_YOUREOPER.
|
||||||
|
func (tc *testClient) becomeOper() {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send("OPER testoper testpass")
|
||||||
|
tc.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 381 ")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationKillRefused covers the KILL errors: not an
|
||||||
|
// operator, no such nick, and killing yourself.
|
||||||
|
func TestIntegrationKillRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
for _, step := range []struct {
|
||||||
|
line, numeric string
|
||||||
|
}{
|
||||||
|
{"KILL bob :nope", " 481 "},
|
||||||
|
{"OPER testoper testpass", " 381 "},
|
||||||
|
{"KILL nobody123 :gone", " 401 "},
|
||||||
|
{"KILL alice :me", " 483 "},
|
||||||
|
} {
|
||||||
|
alice.send(step.line)
|
||||||
|
|
||||||
|
reply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, step.numeric)
|
||||||
|
})
|
||||||
|
assertContains(t, reply, step.numeric, step.line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationKill checks that the victim of a KILL is
|
||||||
|
// told why, is disconnected, and is gone from its channels.
|
||||||
|
func TestIntegrationKill(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
alice.joinAndDrain("#killtest")
|
||||||
|
bob.joinAndDrain("#killtest")
|
||||||
|
|
||||||
|
// Drain alice's view of bob's join.
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
alice.becomeOper()
|
||||||
|
alice.send("KILL bob :bad behavior")
|
||||||
|
|
||||||
|
bobLines := bob.readUntilClosed()
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "KILL",
|
||||||
|
"victim receives KILL before disconnect",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "ERROR :Closing Link",
|
||||||
|
"victim receives ERROR before disconnect",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "bad behavior",
|
||||||
|
"KILL reason delivered to victim",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice should see bob's QUIT relay.
|
||||||
|
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "QUIT") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceSeesQuit, "Killed",
|
||||||
|
"KILL reason in QUIT message",
|
||||||
|
)
|
||||||
|
|
||||||
|
// bob must be gone from the channel member list.
|
||||||
|
alice.send("NAMES #killtest")
|
||||||
|
|
||||||
|
aliceNames := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNames, "alice",
|
||||||
|
"alice still in NAMES after killing bob",
|
||||||
|
)
|
||||||
|
assertNotContains(
|
||||||
|
t, aliceNames, "bob",
|
||||||
|
"killed user must not appear in NAMES",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ...nor from WHO.
|
||||||
|
alice.send("WHO #killtest")
|
||||||
|
|
||||||
|
aliceWho := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 315 ")
|
||||||
|
})
|
||||||
|
assertNotContains(
|
||||||
|
t, aliceWho, "bob",
|
||||||
|
"killed user must not appear in WHO",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationWallops verifies the WALLOPS command:
|
||||||
|
// oper can broadcast to +w users.
|
||||||
|
func TestIntegrationWallops(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Non-oper WALLOPS should fail.
|
||||||
|
alice.send("WALLOPS :test broadcast")
|
||||||
|
|
||||||
|
aliceWallopsFail := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 481 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceWallopsFail, " 481 ",
|
||||||
|
"ERR_NOPRIVILEGES for non-oper WALLOPS",
|
||||||
|
)
|
||||||
|
|
||||||
|
alice.becomeOper()
|
||||||
|
|
||||||
|
// bob sets +w to receive wallops.
|
||||||
|
bob.send("MODE bob +w")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 221 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// alice sends WALLOPS.
|
||||||
|
alice.send("WALLOPS :important announcement")
|
||||||
|
|
||||||
|
// bob (who has +w) should receive it.
|
||||||
|
bobWallops := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(
|
||||||
|
l, "important announcement",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobWallops, "important announcement",
|
||||||
|
"bob receives WALLOPS message",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobWallops, "WALLOPS",
|
||||||
|
"message is WALLOPS command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationUserMode covers MODE for a nick: changes
|
||||||
|
// and queries of your own modes in any letter case, refusal
|
||||||
|
// for another nick, and a rejected mode string changing
|
||||||
|
// nothing.
|
||||||
|
func TestIntegrationUserMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
const (
|
||||||
|
notYours = " 502 alice :Can't change mode for other users"
|
||||||
|
unknown = " 501 alice :Unknown MODE flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, step := range []struct {
|
||||||
|
line, want string
|
||||||
|
}{
|
||||||
|
{"MODE ALICE +w", " 221 alice +w"},
|
||||||
|
{"MODE bob", notYours},
|
||||||
|
{"MODE bob -w", notYours},
|
||||||
|
{"MODE alice xw", unknown},
|
||||||
|
{"MODE alice -w+z", unknown},
|
||||||
|
{"MODE alice", " 221 alice +w"},
|
||||||
|
{"MODE alice +w-w", " 221 alice +"},
|
||||||
|
} {
|
||||||
|
alice.send(step.line)
|
||||||
|
|
||||||
|
reply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 221 ") ||
|
||||||
|
strings.Contains(l, " 501 ") ||
|
||||||
|
strings.Contains(l, " 502 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
last := reply[len(reply)-1]
|
||||||
|
if !strings.HasSuffix(last, step.want) {
|
||||||
|
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIntegrationModeSecret tests +s (secret) channel
|
// TestIntegrationModeSecret tests +s (secret) channel
|
||||||
// mode — verifies that +s can be set and the mode is
|
// mode — verifies that +s can be set and the mode is
|
||||||
// reflected in MODE queries.
|
// reflected in MODE queries.
|
||||||
@@ -915,3 +1282,30 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
|
|||||||
"carol receives trio message",
|
"carol receives trio message",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIntegrationDefaultServerNameFallback checks that with
|
||||||
|
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and
|
||||||
|
// TIME name the server "neoirc".
|
||||||
|
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithConfig(t, &config.Config{})
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
for _, step := range []struct {
|
||||||
|
line, lastNumeric, want string
|
||||||
|
}{
|
||||||
|
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
|
||||||
|
{"ADMIN", " 259 ", " 256 alice neoirc "},
|
||||||
|
{"TIME", " 391 ", " 391 alice neoirc "},
|
||||||
|
} {
|
||||||
|
alice.send(step.line)
|
||||||
|
|
||||||
|
reply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, step.lastNumeric)
|
||||||
|
})
|
||||||
|
assertContains(t, reply, step.want, step.line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -337,6 +339,18 @@ func (c *Conn) deliverInviteMsg(
|
|||||||
c.sendFromServer("NOTICE", nick, text)
|
c.sendFromServer("NOTICE", 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,
|
||||||
|
|||||||
@@ -36,9 +36,39 @@ type testEnv struct {
|
|||||||
srv *ircserver.Server
|
srv *ircserver.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testServerName is the SERVER_NAME of newTestEnv.
|
||||||
|
const testServerName = "test.irc"
|
||||||
|
|
||||||
func newTestEnv(t *testing.T) *testEnv {
|
func newTestEnv(t *testing.T) *testEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestEnvWithConfig(t, &config.Config{
|
||||||
|
ServerName: testServerName,
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvWithOper is newTestEnv with the operator name
|
||||||
|
// testoper and password testpass.
|
||||||
|
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestEnvWithConfig(t, &config.Config{
|
||||||
|
ServerName: testServerName,
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
OperName: "testoper",
|
||||||
|
OperPassword: "testpass",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvWithConfig starts an IRC server with cfg on a
|
||||||
|
// fresh database.
|
||||||
|
func newTestEnvWithConfig(
|
||||||
|
t *testing.T,
|
||||||
|
cfg *config.Config,
|
||||||
|
) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
t.Name(),
|
t.Name(),
|
||||||
@@ -67,11 +97,6 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
|
|
||||||
brk := broker.New()
|
brk := broker.New()
|
||||||
|
|
||||||
cfg := &config.Config{ //nolint:exhaustruct
|
|
||||||
ServerName: "test.irc",
|
|
||||||
MOTD: "Welcome to test IRC",
|
|
||||||
}
|
|
||||||
|
|
||||||
var listenConfig net.ListenConfig
|
var listenConfig net.ListenConfig
|
||||||
|
|
||||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||||
@@ -215,6 +240,33 @@ func (tc *testClient) register(nick string) []string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readUntilClosed returns the lines received until the
|
||||||
|
// server closes the connection, and fails the test if it is
|
||||||
|
// still open after testTimeout.
|
||||||
|
func (tc *testClient) readUntilClosed() []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for tc.scanner.Scan() {
|
||||||
|
lines = append(lines, tc.scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tc.scanner.Err()
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf(
|
||||||
|
"connection not closed: %v (lines: %v)",
|
||||||
|
err, lines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
// assertContains checks that at least one line matches the
|
// assertContains checks that at least one line matches the
|
||||||
// given substring.
|
// given substring.
|
||||||
func assertContains(
|
func assertContains(
|
||||||
@@ -233,6 +285,27 @@ func assertContains(
|
|||||||
t.Errorf("did not find %q in output: %s", substr, description)
|
t.Errorf("did not find %q in output: %s", substr, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertNotContains checks that no line matches the given
|
||||||
|
// substring.
|
||||||
|
func assertNotContains(
|
||||||
|
t *testing.T,
|
||||||
|
lines []string,
|
||||||
|
substr, description string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, substr) {
|
||||||
|
t.Errorf(
|
||||||
|
"unexpectedly found %q in output: %s",
|
||||||
|
substr, description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// joinAndDrain joins a channel and reads until
|
// joinAndDrain joins a channel and reads until
|
||||||
// RPL_ENDOFNAMES.
|
// RPL_ENDOFNAMES.
|
||||||
func (tc *testClient) joinAndDrain(channel string) {
|
func (tc *testClient) joinAndDrain(channel string) {
|
||||||
|
|||||||
+361
-14
@@ -5,16 +5,21 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"sneak.berlin/go/neoirc/internal/logger"
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
@@ -22,9 +27,14 @@ import (
|
|||||||
// Error texts that several commands reply with.
|
// Error texts that several commands reply with.
|
||||||
const (
|
const (
|
||||||
msgNoSuchChannel = "No such channel"
|
msgNoSuchChannel = "No such channel"
|
||||||
|
msgNoSuchNick = "No such nick/channel"
|
||||||
msgNotChannelOp = "You're not channel operator"
|
msgNotChannelOp = "You're not channel operator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxUserhostNicks is how many nicks one USERHOST answers
|
||||||
|
// for (RFC 2812).
|
||||||
|
const maxUserhostNicks = 5
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Service.
|
// Params defines the dependencies for creating a Service.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -33,23 +43,38 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Broker *broker.Broker
|
Broker *broker.Broker
|
||||||
|
Globals *globals.Globals
|
||||||
|
}
|
||||||
|
|
||||||
|
// WireConn is a registered IRC connection, which KILL closes.
|
||||||
|
// HTTP clients hold no connection and register none.
|
||||||
|
type WireConn interface {
|
||||||
|
// Disconnect tells the client why and closes the
|
||||||
|
// connection.
|
||||||
|
Disconnect(reason string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
globals *globals.Globals
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
wireMu sync.Mutex
|
||||||
|
wireConns map[int64]WireConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(),
|
globals: params.Globals,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,16 +84,205 @@ func NewTestService(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
globs *globals.Globals,
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: database,
|
db: database,
|
||||||
broker: brk,
|
broker: brk,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
log: log,
|
globals: globs,
|
||||||
|
log: log,
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerVersion returns the version that VERSION and INFO
|
||||||
|
// report on both transports, such as "neoirc-1.2.3".
|
||||||
|
func (s *Service) ServerVersion() string {
|
||||||
|
version := s.globals.Version
|
||||||
|
if version == "" {
|
||||||
|
version = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.globals.Appname + "-" + version
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoLines returns the lines that INFO sends on both
|
||||||
|
// transports.
|
||||||
|
func (s *Service) InfoLines() []string {
|
||||||
|
return []string{
|
||||||
|
"neoirc — IRC semantics over HTTP",
|
||||||
|
"Version: " + s.ServerVersion(),
|
||||||
|
"Written in Go",
|
||||||
|
"Started: " + s.globals.StartTime.Format(time.RFC1123),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserhostReply returns the RPL_USERHOST text for the first
|
||||||
|
// five of the given nicks: "nick=+user@host" entries joined
|
||||||
|
// by spaces, with * after the nick of an operator and - in
|
||||||
|
// place of + for a user who is away. Nicks with no session
|
||||||
|
// are left out. An empty hostname is reported as
|
||||||
|
// serverName.
|
||||||
|
func (s *Service) UserhostReply(
|
||||||
|
ctx context.Context,
|
||||||
|
nicks []string,
|
||||||
|
serverName string,
|
||||||
|
) (string, error) {
|
||||||
|
if len(nicks) > maxUserhostNicks {
|
||||||
|
nicks = nicks[:maxUserhostNicks]
|
||||||
|
}
|
||||||
|
|
||||||
|
infos, err := s.db.GetUserhostInfo(ctx, nicks)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("userhost: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replies := make([]string, 0, len(infos))
|
||||||
|
|
||||||
|
for _, info := range infos {
|
||||||
|
operStar := ""
|
||||||
|
if info.IsOper {
|
||||||
|
operStar = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
away := "+"
|
||||||
|
if info.AwayMessage != "" {
|
||||||
|
away = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
username := info.Username
|
||||||
|
if username == "" {
|
||||||
|
username = info.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := info.Hostname
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = serverName
|
||||||
|
}
|
||||||
|
|
||||||
|
replies = append(replies,
|
||||||
|
info.Nick+operStar+"="+away+username+"@"+hostname,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(replies, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWireConn records the IRC connection of a session,
|
||||||
|
// so that KillUser can close it.
|
||||||
|
func (s *Service) RegisterWireConn(
|
||||||
|
sessionID int64,
|
||||||
|
conn WireConn,
|
||||||
|
) {
|
||||||
|
s.wireMu.Lock()
|
||||||
|
defer s.wireMu.Unlock()
|
||||||
|
|
||||||
|
s.wireConns[sessionID] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterWireConn removes what RegisterWireConn recorded,
|
||||||
|
// unless the session has since registered another
|
||||||
|
// connection.
|
||||||
|
func (s *Service) UnregisterWireConn(
|
||||||
|
sessionID int64,
|
||||||
|
conn WireConn,
|
||||||
|
) {
|
||||||
|
s.wireMu.Lock()
|
||||||
|
defer s.wireMu.Unlock()
|
||||||
|
|
||||||
|
if s.wireConns[sessionID] == conn {
|
||||||
|
delete(s.wireConns, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillUser carries out an operator's KILL: the target's
|
||||||
|
// channel peers see it quit, its session is deleted, and
|
||||||
|
// its IRC connection, if it has one, is closed.
|
||||||
|
func (s *Service) KillUser(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, targetNick, reason string,
|
||||||
|
) error {
|
||||||
|
err := s.requireOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{targetNick},
|
||||||
|
msgNoSuchNick,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("kill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetSID == sessionID {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrCantKillServer,
|
||||||
|
nil,
|
||||||
|
"You cannot KILL yourself",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||||
|
|
||||||
|
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
|
||||||
|
|
||||||
|
s.wireMu.Lock()
|
||||||
|
conn := s.wireConns[targetSID]
|
||||||
|
s.wireMu.Unlock()
|
||||||
|
|
||||||
|
if conn != nil {
|
||||||
|
conn.Disconnect(quitReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendWallops carries out an operator's WALLOPS: the
|
||||||
|
// message goes to every user with user mode +w.
|
||||||
|
func (s *Service) SendWallops(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, message string,
|
||||||
|
) error {
|
||||||
|
err := s.requireOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients, err := s.db.GetWallopsSessionIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wallops: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal([]string{message})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wallops: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = s.FanOut(
|
||||||
|
ctx, irc.CmdWallops, nick, "*",
|
||||||
|
nil, body, nil, recipients,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wallops: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// IRCError represents an IRC protocol-level error with a
|
// IRCError represents an IRC protocol-level error with a
|
||||||
// numeric code that both transports can map to responses.
|
// numeric code that both transports can map to responses.
|
||||||
type IRCError struct {
|
type IRCError struct {
|
||||||
@@ -437,7 +651,7 @@ func (s *Service) KickUser(
|
|||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchNick,
|
irc.ErrNoSuchNick,
|
||||||
[]string{targetNick},
|
[]string{targetNick},
|
||||||
"No such nick/channel",
|
msgNoSuchNick,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +863,7 @@ func (s *Service) ApplyMemberMode(
|
|||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchNick,
|
irc.ErrNoSuchNick,
|
||||||
[]string{targetNick},
|
[]string{targetNick},
|
||||||
"No such nick/channel",
|
msgNoSuchNick,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,6 +1006,139 @@ func (s *Service) QueryChannelMode(
|
|||||||
return modes + modeParams
|
return modes + modeParams
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryUserMode returns the session's user mode string,
|
||||||
|
// such as "+", "+w" or "+ow".
|
||||||
|
func (s *Service) QueryUserMode(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (string, error) {
|
||||||
|
modes := "+"
|
||||||
|
|
||||||
|
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"query oper flag: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOper {
|
||||||
|
modes += "o"
|
||||||
|
}
|
||||||
|
|
||||||
|
isWallops, err := s.db.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"query wallops flag: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWallops {
|
||||||
|
modes += "w"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyUserMode applies a user mode string to the session
|
||||||
|
// and returns the resulting mode string. The whole string
|
||||||
|
// is parsed before anything is written, and the flags are
|
||||||
|
// written in one transaction, so a change that is rejected
|
||||||
|
// or fails leaves the modes as they were.
|
||||||
|
func (s *Service) ApplyUserMode(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
modeStr string,
|
||||||
|
) (string, error) {
|
||||||
|
wallops, oper, err := parseUserModeString(modeStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.db.SetSessionUserModes(
|
||||||
|
ctx, sessionID, wallops, oper,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apply user modes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.QueryUserMode(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUserModeString parses a user mode string such as
|
||||||
|
// "+w", "-o" or "+w-o" into the new value of each flag it
|
||||||
|
// names, or nil for a flag it does not name; a later letter
|
||||||
|
// overrides an earlier one. The string must start with + or
|
||||||
|
// - and name at least one mode. w may be set or unset, o
|
||||||
|
// only unset (OPER sets it). Anything else is rejected with
|
||||||
|
// ERR_UMODEUNKNOWNFLAG.
|
||||||
|
func parseUserModeString(
|
||||||
|
modeStr string,
|
||||||
|
) (*bool, *bool, error) {
|
||||||
|
unknownFlag := &IRCError{
|
||||||
|
irc.ErrUmodeUnknownFlag, nil, "Unknown MODE flag",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(modeStr, "+") &&
|
||||||
|
!strings.HasPrefix(modeStr, "-") {
|
||||||
|
return nil, nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
var wallops, oper *bool
|
||||||
|
|
||||||
|
adding := true
|
||||||
|
|
||||||
|
for _, modeChar := range modeStr {
|
||||||
|
switch modeChar {
|
||||||
|
case '+':
|
||||||
|
adding = true
|
||||||
|
case '-':
|
||||||
|
adding = false
|
||||||
|
case 'w':
|
||||||
|
value := adding
|
||||||
|
wallops = &value
|
||||||
|
case 'o':
|
||||||
|
if adding {
|
||||||
|
return nil, nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
value := false
|
||||||
|
oper = &value
|
||||||
|
default:
|
||||||
|
return nil, nil, unknownFlag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if wallops == nil && oper == nil {
|
||||||
|
return nil, nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
return wallops, oper, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireOper returns ERR_NOPRIVILEGES unless the session
|
||||||
|
// is a server operator.
|
||||||
|
func (s *Service) requireOper(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) error {
|
||||||
|
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check oper: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOper {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoPrivileges,
|
||||||
|
nil,
|
||||||
|
"Permission Denied- You're not an IRC operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// broadcastNickChange notifies channel peers of a nick
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
// change.
|
// change.
|
||||||
func (s *Service) broadcastNickChange(
|
func (s *Service) broadcastNickChange(
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
@@ -55,9 +58,10 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
app := fxtest.New(t,
|
app := fxtest.New(t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
func() *globals.Globals {
|
func() *globals.Globals {
|
||||||
return &globals.Globals{ //nolint:exhaustruct
|
return &globals.Globals{
|
||||||
Appname: "neoirc-test",
|
Appname: "neoirc-test",
|
||||||
Version: "test",
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -363,3 +367,229 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryUserMode(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
modes, err := env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != "+" {
|
||||||
|
t.Errorf("expected +, got %s", modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserModes(ctx, t, env.db, sid, userModes{wallops: true})
|
||||||
|
|
||||||
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != "+w" {
|
||||||
|
t.Errorf("expected +w, got %s", modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserModes(
|
||||||
|
ctx, t, env.db, sid, userModes{oper: true, wallops: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != "+ow" {
|
||||||
|
t.Errorf("expected +ow, got %s", modes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserhostReply(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
createSession(ctx, t, env.db, "alice")
|
||||||
|
bobID := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
// No username or hostname, and an operator.
|
||||||
|
operID, _, _, err := env.db.CreateSession(
|
||||||
|
ctx, "oper", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserModes(ctx, t, env.db, operID, userModes{oper: true})
|
||||||
|
|
||||||
|
_, err = env.svc.SetAway(ctx, bobID, "gone fishing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := env.svc.UserhostReply(ctx, []string{
|
||||||
|
"alice", "bob", "nobody", "oper",
|
||||||
|
}, "srv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "alice=+alice@localhost bob=-bob@localhost " +
|
||||||
|
"oper*=+oper@srv"
|
||||||
|
if reply != want {
|
||||||
|
t.Errorf("want %q, got %q", want, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err = env.svc.UserhostReply(
|
||||||
|
ctx, slices.Repeat([]string{"bob"}, 6), "srv",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Count(reply, "bob=") != 5 {
|
||||||
|
t.Errorf("want 5 entries for 6 nicks, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userModes is the stored state of a session's user mode
|
||||||
|
// flags.
|
||||||
|
type userModes struct {
|
||||||
|
oper bool
|
||||||
|
wallops bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeTest is one mode string, the flags stored
|
||||||
|
// before it is applied, and the expected outcome. An empty
|
||||||
|
// wantModes means the string is rejected with
|
||||||
|
// ERR_UMODEUNKNOWNFLAG, and then after must equal before.
|
||||||
|
type applyUserModeTest struct {
|
||||||
|
modeStr string
|
||||||
|
before userModes
|
||||||
|
wantModes string
|
||||||
|
after userModes
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUserModeTests() []applyUserModeTest {
|
||||||
|
none := userModes{}
|
||||||
|
oper := userModes{oper: true}
|
||||||
|
wallops := userModes{wallops: true}
|
||||||
|
both := userModes{oper: true, wallops: true}
|
||||||
|
|
||||||
|
return []applyUserModeTest{
|
||||||
|
{"+w", none, "+w", wallops},
|
||||||
|
{"-w", wallops, "+", none},
|
||||||
|
{"-o", oper, "+", none},
|
||||||
|
{"-wo", both, "+", none},
|
||||||
|
{"+w-o", oper, "+w", wallops},
|
||||||
|
{"-w+w", none, "+w", wallops},
|
||||||
|
{"+w-w", wallops, "+", none},
|
||||||
|
{"-w+o", both, "", both},
|
||||||
|
{"+o-w+w", both, "", both},
|
||||||
|
{"+wo", none, "", none},
|
||||||
|
{"+wz", none, "", none},
|
||||||
|
{"+z", none, "", none},
|
||||||
|
{"-x+y", none, "", none},
|
||||||
|
{"+y-x", none, "", none},
|
||||||
|
{"w", none, "", none},
|
||||||
|
{"xw", wallops, "", wallops},
|
||||||
|
{"", none, "", none},
|
||||||
|
{"+", none, "", none},
|
||||||
|
{"-", none, "", none},
|
||||||
|
{"+-+", none, "", none},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUserMode(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
for i, test := range applyUserModeTests() {
|
||||||
|
t.Run(test.modeStr, func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
sid := createSession(
|
||||||
|
ctx, t, env.db, fmt.Sprintf("user%d", i),
|
||||||
|
)
|
||||||
|
setUserModes(ctx, t, env.db, sid, test.before)
|
||||||
|
|
||||||
|
modes, err := env.svc.ApplyUserMode(
|
||||||
|
ctx, sid, test.modeStr,
|
||||||
|
)
|
||||||
|
checkApplyUserModeResult(t, test.wantModes, modes, err)
|
||||||
|
|
||||||
|
got := getUserModes(ctx, t, env.db, sid)
|
||||||
|
if got != test.after {
|
||||||
|
t.Errorf(
|
||||||
|
"stored modes: want %+v, got %+v",
|
||||||
|
test.after, got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkApplyUserModeResult(
|
||||||
|
t *testing.T,
|
||||||
|
wantModes, modes string,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if wantModes == "" {
|
||||||
|
var ircErr *service.IRCError
|
||||||
|
if !errors.As(err, &ircErr) ||
|
||||||
|
ircErr.Code != irc.ErrUmodeUnknownFlag {
|
||||||
|
t.Fatalf("want ERR_UMODEUNKNOWNFLAG, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != wantModes {
|
||||||
|
t.Errorf("modes: want %q, got %q", wantModes, modes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUserModes(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
sid int64,
|
||||||
|
modes userModes,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := database.SetSessionUserModes(
|
||||||
|
ctx, sid, &modes.wallops, &modes.oper,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set user modes: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserModes(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
sid int64,
|
||||||
|
) userModes {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oper, err := database.IsSessionOper(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read oper: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wallops, err := database.IsSessionWallops(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wallops: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userModes{oper: oper, wallops: wallops}
|
||||||
|
}
|
||||||
|
|||||||
+29
-22
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-11
@@ -2,22 +2,16 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Install esbuild if not present
|
# The esbuild that yarn.lock pins, which yarn install puts in node_modules
|
||||||
if ! command -v esbuild >/dev/null 2>&1; then
|
if [ ! -x node_modules/.bin/esbuild ]; then
|
||||||
if command -v npx >/dev/null 2>&1; then
|
echo "esbuild not found. Install it: yarn install --frozen-lockfile"
|
||||||
NPX="npx"
|
exit 1
|
||||||
else
|
|
||||||
echo "esbuild not found. Install it: npm install -g esbuild"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
NPX=""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
||||||
${NPX:+$NPX} esbuild src/app.jsx \
|
node_modules/.bin/esbuild src/app.jsx \
|
||||||
--bundle \
|
--bundle \
|
||||||
--minify \
|
--minify \
|
||||||
--format=esm \
|
--format=esm \
|
||||||
|
|||||||
Generated
-513
@@ -1,513 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "web",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"preact": "^10.28.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.27.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.27.3",
|
|
||||||
"@esbuild/android-arm": "0.27.3",
|
|
||||||
"@esbuild/android-arm64": "0.27.3",
|
|
||||||
"@esbuild/android-x64": "0.27.3",
|
|
||||||
"@esbuild/darwin-arm64": "0.27.3",
|
|
||||||
"@esbuild/darwin-x64": "0.27.3",
|
|
||||||
"@esbuild/freebsd-arm64": "0.27.3",
|
|
||||||
"@esbuild/freebsd-x64": "0.27.3",
|
|
||||||
"@esbuild/linux-arm": "0.27.3",
|
|
||||||
"@esbuild/linux-arm64": "0.27.3",
|
|
||||||
"@esbuild/linux-ia32": "0.27.3",
|
|
||||||
"@esbuild/linux-loong64": "0.27.3",
|
|
||||||
"@esbuild/linux-mips64el": "0.27.3",
|
|
||||||
"@esbuild/linux-ppc64": "0.27.3",
|
|
||||||
"@esbuild/linux-riscv64": "0.27.3",
|
|
||||||
"@esbuild/linux-s390x": "0.27.3",
|
|
||||||
"@esbuild/linux-x64": "0.27.3",
|
|
||||||
"@esbuild/netbsd-arm64": "0.27.3",
|
|
||||||
"@esbuild/netbsd-x64": "0.27.3",
|
|
||||||
"@esbuild/openbsd-arm64": "0.27.3",
|
|
||||||
"@esbuild/openbsd-x64": "0.27.3",
|
|
||||||
"@esbuild/openharmony-arm64": "0.27.3",
|
|
||||||
"@esbuild/sunos-x64": "0.27.3",
|
|
||||||
"@esbuild/win32-arm64": "0.27.3",
|
|
||||||
"@esbuild/win32-ia32": "0.27.3",
|
|
||||||
"@esbuild/win32-x64": "0.27.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/preact": {
|
|
||||||
"version": "10.28.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/preact"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
|
||||||
|
integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
|
||||||
|
|
||||||
|
"@esbuild/android-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8"
|
||||||
|
integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
|
||||||
|
|
||||||
|
"@esbuild/android-arm@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b"
|
||||||
|
integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
|
||||||
|
|
||||||
|
"@esbuild/android-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac"
|
||||||
|
integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd"
|
||||||
|
integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a"
|
||||||
|
integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b"
|
||||||
|
integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead"
|
||||||
|
integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6"
|
||||||
|
integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
|
||||||
|
|
||||||
|
"@esbuild/linux-arm@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11"
|
||||||
|
integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29"
|
||||||
|
integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed"
|
||||||
|
integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1"
|
||||||
|
integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78"
|
||||||
|
integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d"
|
||||||
|
integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d"
|
||||||
|
integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
|
||||||
|
|
||||||
|
"@esbuild/linux-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5"
|
||||||
|
integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7"
|
||||||
|
integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b"
|
||||||
|
integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5"
|
||||||
|
integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b"
|
||||||
|
integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e"
|
||||||
|
integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537"
|
||||||
|
integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e"
|
||||||
|
integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c"
|
||||||
|
integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
|
||||||
|
|
||||||
|
"@esbuild/win32-x64@0.27.3":
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
|
||||||
|
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
|
||||||
|
|
||||||
|
esbuild@^0.27.3:
|
||||||
|
version "0.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8"
|
||||||
|
integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
|
||||||
|
optionalDependencies:
|
||||||
|
"@esbuild/aix-ppc64" "0.27.3"
|
||||||
|
"@esbuild/android-arm" "0.27.3"
|
||||||
|
"@esbuild/android-arm64" "0.27.3"
|
||||||
|
"@esbuild/android-x64" "0.27.3"
|
||||||
|
"@esbuild/darwin-arm64" "0.27.3"
|
||||||
|
"@esbuild/darwin-x64" "0.27.3"
|
||||||
|
"@esbuild/freebsd-arm64" "0.27.3"
|
||||||
|
"@esbuild/freebsd-x64" "0.27.3"
|
||||||
|
"@esbuild/linux-arm" "0.27.3"
|
||||||
|
"@esbuild/linux-arm64" "0.27.3"
|
||||||
|
"@esbuild/linux-ia32" "0.27.3"
|
||||||
|
"@esbuild/linux-loong64" "0.27.3"
|
||||||
|
"@esbuild/linux-mips64el" "0.27.3"
|
||||||
|
"@esbuild/linux-ppc64" "0.27.3"
|
||||||
|
"@esbuild/linux-riscv64" "0.27.3"
|
||||||
|
"@esbuild/linux-s390x" "0.27.3"
|
||||||
|
"@esbuild/linux-x64" "0.27.3"
|
||||||
|
"@esbuild/netbsd-arm64" "0.27.3"
|
||||||
|
"@esbuild/netbsd-x64" "0.27.3"
|
||||||
|
"@esbuild/openbsd-arm64" "0.27.3"
|
||||||
|
"@esbuild/openbsd-x64" "0.27.3"
|
||||||
|
"@esbuild/openharmony-arm64" "0.27.3"
|
||||||
|
"@esbuild/sunos-x64" "0.27.3"
|
||||||
|
"@esbuild/win32-arm64" "0.27.3"
|
||||||
|
"@esbuild/win32-ia32" "0.27.3"
|
||||||
|
"@esbuild/win32-x64" "0.27.3"
|
||||||
|
|
||||||
|
preact@^10.28.3:
|
||||||
|
version "10.28.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.3.tgz#3c2171526b3e29628ad1a6c56a9e3ca867bbdee8"
|
||||||
|
integrity sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==
|
||||||
Reference in New Issue
Block a user