Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28fa005ed | ||
|
|
b119b094f5 | ||
|
|
6acf2ca3b4 |
+8
-2
@@ -1,9 +1,15 @@
|
||||
# Web build stage — compile SPA from source
|
||||
# node:22-alpine, 2026-03-09
|
||||
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
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/package.json web/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY web/src/ src/
|
||||
COPY web/build.sh build.sh
|
||||
RUN sh build.sh
|
||||
|
||||
@@ -2389,8 +2389,8 @@ IRC_LISTEN_ADDR=
|
||||
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||||
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||||
| Messaging | `PRIVMSG`, `NOTICE` |
|
||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` |
|
||||
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
||||
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||
|
||||
### Protocol Details
|
||||
|
||||
@@ -2406,6 +2406,15 @@ IRC_LISTEN_ADDR=
|
||||
operator status (`@`).
|
||||
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v`
|
||||
(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
|
||||
|
||||
@@ -2946,6 +2955,10 @@ guess is borne by the server (bcrypt), not the client.
|
||||
from additional devices via `POST /api/v1/login`
|
||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API
|
||||
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+)
|
||||
|
||||
@@ -3020,7 +3033,7 @@ neoirc/
|
||||
│ ├── embed.go # go:embed directive for SPA
|
||||
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||
│ ├── package-lock.json
|
||||
│ ├── yarn.lock
|
||||
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||
│ │ ├── app.jsx
|
||||
│ │ ├── index.html
|
||||
|
||||
@@ -58,3 +58,19 @@ func (database *Database) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecForTest runs a raw statement against the test
|
||||
// database. Tests use it to install SQLite triggers that
|
||||
// force a specific write to fail, so that the atomicity of
|
||||
// multi-statement helpers can be exercised.
|
||||
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"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -2423,3 +2424,205 @@ func (database *Database) SetChannelUserLimit(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionUserModes applies a set of user-mode flag
|
||||
// changes to a session inside a single transaction, so a
|
||||
// multi-mode change such as "+w-o" is all-or-nothing. A nil
|
||||
// pointer means the caller did not mention that mode and
|
||||
// the stored value must be left untouched.
|
||||
func (database *Database) SetSessionUserModes(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
wallops *bool,
|
||||
oper *bool,
|
||||
) error {
|
||||
if wallops == nil && oper == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
if wallops != nil {
|
||||
if _, err := transaction.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||
boolToInt(*wallops), sessionID,
|
||||
); err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf(
|
||||
"set session wallops: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if oper != nil {
|
||||
if _, err := transaction.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||
boolToInt(*oper), sessionID,
|
||||
); err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf("set session oper: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := transaction.Commit(); err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf("commit user modes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// boolToInt renders a Go bool as the 0/1 integer used for
|
||||
// boolean columns in the SQLite schema.
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// SetSessionWallops sets the wallops (+w) flag on a
|
||||
// session.
|
||||
func (database *Database) SetSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
enabled bool,
|
||||
) error {
|
||||
val := 0
|
||||
if enabled {
|
||||
val = 1
|
||||
}
|
||||
|
||||
_, err := database.conn.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||
val, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set session wallops: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSessionWallops returns whether the session has the
|
||||
// wallops (+w) usermode set.
|
||||
func (database *Database) IsSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
var isWallops int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&isWallops)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check session wallops: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return isWallops != 0, nil
|
||||
}
|
||||
|
||||
// GetWallopsSessionIDs returns all session IDs that have
|
||||
// the wallops (+w) usermode set.
|
||||
func (database *Database) GetWallopsSessionIDs(
|
||||
ctx context.Context,
|
||||
) ([]int64, error) {
|
||||
rows, err := database.conn.QueryContext(
|
||||
ctx,
|
||||
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var ids []int64
|
||||
|
||||
for rows.Next() {
|
||||
var sessionID int64
|
||||
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"scan wallops session: %w", scanErr,
|
||||
)
|
||||
}
|
||||
|
||||
ids = append(ids, sessionID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"iterate wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UserhostInfo holds the data needed for RPL_USERHOST.
|
||||
type UserhostInfo struct {
|
||||
Nick string
|
||||
Username string
|
||||
Hostname string
|
||||
IsOper bool
|
||||
AwayMessage string
|
||||
}
|
||||
|
||||
// GetUserhostInfo returns USERHOST info for the given
|
||||
// nicks. Nicks with no session are omitted from the
|
||||
// result; any other database failure is returned, because
|
||||
// an unreadable row is not the same as an absent one.
|
||||
func (database *Database) GetUserhostInfo(
|
||||
ctx context.Context,
|
||||
nicks []string,
|
||||
) ([]UserhostInfo, error) {
|
||||
if len(nicks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results := make([]UserhostInfo, 0, len(nicks))
|
||||
|
||||
for _, nick := range nicks {
|
||||
var info UserhostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT nick, username, hostname,
|
||||
is_oper, away_message
|
||||
FROM sessions WHERE nick = ?`,
|
||||
nick,
|
||||
).Scan(
|
||||
&info.Nick, &info.Username, &info.Hostname,
|
||||
&info.IsOper, &info.AwayMessage,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue // nick not online
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"userhost lookup %q: %w", nick, err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, info)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -1491,3 +1491,127 @@ func TestChannelUserLimit(t *testing.T) {
|
||||
t.Fatalf("expected 0, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSessionUserModesIsAtomic proves that a multi-mode
|
||||
// change is all-or-nothing. A trigger makes the is_oper
|
||||
// UPDATE fail after the is_wallops UPDATE has already run,
|
||||
// which is exactly the "+w-o" partial-failure the previous
|
||||
// implementation exhibited: it issued the two UPDATEs
|
||||
// independently, so +w persisted while the caller reported
|
||||
// total failure.
|
||||
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 persisted despite the transaction " +
|
||||
"failing; the apply stage is not atomic",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSessionUserModesAppliesBoth is the success-path
|
||||
// counterpart: when nothing fails, both flags are written,
|
||||
// and a nil pointer leaves that flag untouched.
|
||||
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)
|
||||
}
|
||||
|
||||
if err := database.SetSessionOper(
|
||||
ctx, sessionID, true,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wallops := true
|
||||
oper := false
|
||||
|
||||
if err := database.SetSessionUserModes(
|
||||
ctx, sessionID, &wallops, &oper,
|
||||
); 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,
|
||||
)
|
||||
}
|
||||
|
||||
// A nil pointer must leave the stored value alone.
|
||||
if err := database.SetSessionUserModes(
|
||||
ctx, sessionID, nil, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotWallops, err = database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !gotWallops {
|
||||
t.Error("nil pointers must not clear wallops")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||
is_wallops INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
signing_key TEXT NOT NULL DEFAULT '',
|
||||
away_message TEXT NOT NULL DEFAULT '',
|
||||
|
||||
+51
-31
@@ -962,10 +962,12 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdAway:
|
||||
hdlr.handleAway(
|
||||
case irc.CmdAway, irc.CmdNick,
|
||||
irc.CmdPass, irc.CmdInvite:
|
||||
hdlr.dispatchBodyOnlyCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
sessionID, clientID, nick,
|
||||
command, bodyLines,
|
||||
)
|
||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||
hdlr.handlePrivmsg(
|
||||
@@ -984,27 +986,12 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target, body,
|
||||
)
|
||||
case irc.CmdNick:
|
||||
hdlr.handleNick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPass:
|
||||
hdlr.handlePass(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdTopic:
|
||||
hdlr.handleTopic(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdInvite:
|
||||
hdlr.handleInvite(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdKick:
|
||||
hdlr.handleKick(
|
||||
writer, request,
|
||||
@@ -1015,12 +1002,15 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
hdlr.handleQuit(
|
||||
writer, request, sessionID, nick, body,
|
||||
)
|
||||
case irc.CmdOper:
|
||||
hdlr.handleOper(
|
||||
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
|
||||
hdlr.dispatchOperCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
sessionID, clientID, nick,
|
||||
command, bodyLines,
|
||||
)
|
||||
case irc.CmdMotd, irc.CmdPing:
|
||||
case irc.CmdMotd, irc.CmdPing,
|
||||
irc.CmdVersion, irc.CmdAdmin,
|
||||
irc.CmdInfo, irc.CmdTime:
|
||||
hdlr.dispatchInfoCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
@@ -1075,6 +1065,11 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdUserhost:
|
||||
hdlr.handleUserhost(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
default:
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
@@ -1868,7 +1863,8 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
|
||||
}
|
||||
|
||||
// dispatchInfoCommand handles informational IRC commands
|
||||
// that produce server-side numerics (MOTD, PING).
|
||||
// that produce server-side numerics (MOTD, PING,
|
||||
// VERSION, ADMIN, INFO, TIME).
|
||||
func (hdlr *Handlers) dispatchInfoCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -1894,6 +1890,34 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
||||
},
|
||||
http.StatusOK)
|
||||
|
||||
return
|
||||
case irc.CmdVersion:
|
||||
hdlr.handleVersion(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
case irc.CmdAdmin:
|
||||
hdlr.handleAdmin(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
case irc.CmdInfo:
|
||||
hdlr.handleInfo(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
case irc.CmdTime:
|
||||
hdlr.handleTime(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1951,15 +1975,11 @@ func (hdlr *Handlers) handleMode(
|
||||
|
||||
channel := target
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
// User mode query — return empty modes.
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplUmodeIs, nick, nil, "+",
|
||||
hdlr.handleUserMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target,
|
||||
bodyLines,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ func newTestHandlers(
|
||||
Config: cfg,
|
||||
Database: database,
|
||||
Broker: brk,
|
||||
Globals: globs,
|
||||
})
|
||||
|
||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// maxUserhostNicks is the maximum number of nicks allowed
|
||||
// in a single USERHOST query (RFC 2812).
|
||||
const maxUserhostNicks = 5
|
||||
|
||||
// dispatchBodyOnlyCommand routes commands that take
|
||||
// (writer, request, sessionID, clientID, nick, bodyLines).
|
||||
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, command string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdAway:
|
||||
hdlr.handleAway(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdNick:
|
||||
hdlr.handleNick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPass:
|
||||
hdlr.handlePass(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdInvite:
|
||||
hdlr.handleInvite(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchOperCommand routes oper-related commands (OPER,
|
||||
// KILL, WALLOPS) to their handlers.
|
||||
func (hdlr *Handlers) dispatchOperCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, command string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdOper:
|
||||
hdlr.handleOper(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdKill:
|
||||
hdlr.handleKill(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdWallops:
|
||||
hdlr.handleWallops(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserhost handles the USERHOST command.
|
||||
// Returns user@host info for up to 5 nicks.
|
||||
func (hdlr *Handlers) handleUserhost(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdUserhost},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Limit to 5 nicks per RFC 2812.
|
||||
nicks := lines
|
||||
if len(nicks) > maxUserhostNicks {
|
||||
nicks = nicks[:maxUserhostNicks]
|
||||
}
|
||||
|
||||
infos, err := hdlr.params.Database.GetUserhostInfo(
|
||||
ctx, nicks,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"userhost query failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
replyStr := hdlr.buildUserhostReply(infos)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUserHost, nick, nil,
|
||||
replyStr,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// buildUserhostReply builds the RPL_USERHOST reply
|
||||
// string per RFC 2812.
|
||||
func (hdlr *Handlers) buildUserhostReply(
|
||||
infos []db.UserhostInfo,
|
||||
) string {
|
||||
replies := make([]string, 0, len(infos))
|
||||
|
||||
for idx := range infos {
|
||||
info := &infos[idx]
|
||||
|
||||
username := info.Username
|
||||
if username == "" {
|
||||
username = info.Nick
|
||||
}
|
||||
|
||||
hostname := info.Hostname
|
||||
if hostname == "" {
|
||||
hostname = hdlr.serverName()
|
||||
}
|
||||
|
||||
operStar := ""
|
||||
if info.IsOper {
|
||||
operStar = "*"
|
||||
}
|
||||
|
||||
awayPrefix := "+"
|
||||
if info.AwayMessage != "" {
|
||||
awayPrefix = "-"
|
||||
}
|
||||
|
||||
replies = append(replies,
|
||||
info.Nick+operStar+"="+
|
||||
awayPrefix+username+"@"+hostname,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Join(replies, " ")
|
||||
}
|
||||
|
||||
// handleVersion handles the VERSION command.
|
||||
// Returns the server version string.
|
||||
func (hdlr *Handlers) handleVersion(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
version := hdlr.svc.ServerVersion()
|
||||
|
||||
// 351 RPL_VERSION
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplVersion, nick,
|
||||
[]string{version + ".", srvName},
|
||||
"",
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleAdmin handles the ADMIN command.
|
||||
// Returns server admin contact info.
|
||||
func (hdlr *Handlers) handleAdmin(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
|
||||
// 256 RPL_ADMINME
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminMe, nick,
|
||||
[]string{srvName},
|
||||
"Administrative info",
|
||||
)
|
||||
|
||||
// 257 RPL_ADMINLOC1
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
||||
"neoirc server",
|
||||
)
|
||||
|
||||
// 258 RPL_ADMINLOC2
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
||||
"IRC over HTTP",
|
||||
)
|
||||
|
||||
// 259 RPL_ADMINEMAIL
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
||||
"admin@"+srvName,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleInfo handles the INFO command.
|
||||
// Returns server software information.
|
||||
func (hdlr *Handlers) handleInfo(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
for _, line := range hdlr.svc.InfoLines() {
|
||||
// 371 RPL_INFO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplInfo, nick, nil,
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
// 374 RPL_ENDOFINFO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
||||
"End of /INFO list",
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleTime handles the TIME command.
|
||||
// Returns the server's local time in RFC format.
|
||||
func (hdlr *Handlers) handleTime(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
|
||||
// 391 RPL_TIME
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplTime, nick,
|
||||
[]string{srvName},
|
||||
time.Now().Format(time.RFC1123),
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleKill handles the KILL command.
|
||||
// Forcibly disconnects a user (oper only).
|
||||
func (hdlr *Handlers) handleKill(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Check oper status.
|
||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoPrivileges, nick, nil,
|
||||
"Permission Denied- You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
|
||||
var targetNick string
|
||||
if len(lines) > 0 {
|
||||
targetNick = strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
if targetNick == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdKill},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reason := "KILLed"
|
||||
if len(lines) > 1 {
|
||||
reason = lines[1]
|
||||
}
|
||||
|
||||
targetSID, lookupErr := hdlr.params.Database.
|
||||
GetSessionByNick(ctx, targetNick)
|
||||
if lookupErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchNick, nick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Do not allow killing yourself.
|
||||
if targetSID == sessionID {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCantKillServer, nick, nil,
|
||||
"You cannot KILL yourself",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||
|
||||
// KillSession broadcasts the QUIT, deletes the session
|
||||
// and disconnects the victim's wire connection if it
|
||||
// holds one.
|
||||
hdlr.svc.KillSession(
|
||||
ctx, targetSID, targetNick, quitReason,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleWallops handles the WALLOPS command.
|
||||
// Broadcasts a message to all users with +w usermode
|
||||
// (oper only).
|
||||
func (hdlr *Handlers) handleWallops(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Check oper status.
|
||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoPrivileges, nick, nil,
|
||||
"Permission Denied- You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdWallops},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.Join(lines, " ")
|
||||
|
||||
wallopsSIDs, err := hdlr.params.Database.
|
||||
GetWallopsSessionIDs(ctx)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"get wallops sessions failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(wallopsSIDs) > 0 {
|
||||
body, mErr := json.Marshal([]string{message})
|
||||
if mErr != nil {
|
||||
hdlr.log.Error(
|
||||
"marshal wallops body", "error", mErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdWallops, nick, "*",
|
||||
json.RawMessage(body), wallopsSIDs,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleUserMode handles user mode queries and changes
|
||||
// (e.g., MODE nick, MODE nick +w). Delegates to the
|
||||
// shared service.ApplyUserMode / service.QueryUserMode so
|
||||
// that mode string processing is identical for both the
|
||||
// HTTP API and IRC wire protocol.
|
||||
func (hdlr *Handlers) handleUserMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, target string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Users can only query or change their own modes. The
|
||||
// check is above the query/change split so that both
|
||||
// forms are rejected, and uses EqualFold because IRC
|
||||
// nicks are case-insensitive — matching the wire path
|
||||
// in ircserver.handleUserMode.
|
||||
if target != "" && !strings.EqualFold(target, nick) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUsersDoNotMatch, nick, nil,
|
||||
"Can't change mode for other users",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
|
||||
// Mode change requested.
|
||||
if len(lines) > 0 {
|
||||
hdlr.changeUserMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, lines[0],
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Mode query — delegate to shared service.
|
||||
modeStr, err := hdlr.svc.QueryUserMode(ctx, sessionID)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"query user mode failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||
modeStr,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// changeUserMode applies a mode string to the caller's own
|
||||
// session. The caller has already verified that the target
|
||||
// nick is the caller's own.
|
||||
func (hdlr *Handlers) changeUserMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, modeStr string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
newModes, err := hdlr.svc.ApplyUserMode(
|
||||
ctx, sessionID, modeStr,
|
||||
)
|
||||
if err != nil {
|
||||
var ircErr *service.IRCError
|
||||
if errors.As(err, &ircErr) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
clientID, sessionID,
|
||||
ircErr.Code, nick, ircErr.Params,
|
||||
ircErr.Message,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||
newModes,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -345,7 +345,10 @@ func (c *Conn) handleQuit(msg *Message) {
|
||||
|
||||
c.send("ERROR :Closing Link: " + c.hostname +
|
||||
" (Quit: " + reason + ")")
|
||||
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// handleTopic gets or sets a channel topic via the shared
|
||||
@@ -427,7 +430,7 @@ func (c *Conn) handleMode(
|
||||
if strings.HasPrefix(target, "#") {
|
||||
c.handleChannelMode(ctx, msg)
|
||||
} else {
|
||||
c.handleUserMode(msg)
|
||||
c.handleUserMode(ctx, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,10 +690,13 @@ func (c *Conn) applyChannelModes(
|
||||
}
|
||||
|
||||
// handleUserMode handles MODE for users.
|
||||
func (c *Conn) handleUserMode(msg *Message) {
|
||||
func (c *Conn) handleUserMode(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
target := msg.Params[0]
|
||||
|
||||
if !strings.EqualFold(target, c.nick) {
|
||||
if !strings.EqualFold(target, c.currentNick()) {
|
||||
c.sendNumeric(
|
||||
irc.ErrUsersDoNotMatch,
|
||||
"Can't change mode for other users",
|
||||
@@ -699,8 +705,48 @@ func (c *Conn) handleUserMode(msg *Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// We don't support user modes beyond the basics.
|
||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
||||
// Mode query (no mode string).
|
||||
if len(msg.Params) < 2 { //nolint:mnd
|
||||
modes, err := c.svc.QueryUserMode(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"query user mode failed", "error", err,
|
||||
)
|
||||
c.sendNumeric(
|
||||
irc.ErrUmodeUnknownFlag,
|
||||
"Unable to read user modes",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplUmodeIs, modes)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
newModes, err := c.svc.ApplyUserMode(
|
||||
ctx, c.sessionID, msg.Params[1],
|
||||
)
|
||||
if err != nil {
|
||||
var ircErr *service.IRCError
|
||||
if errors.As(err, &ircErr) {
|
||||
c.sendNumeric(ircErr.Code, ircErr.Message)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(
|
||||
irc.ErrUmodeUnknownFlag,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplUmodeIs, newModes)
|
||||
}
|
||||
|
||||
// handleNames replies with channel member list.
|
||||
@@ -1292,3 +1338,180 @@ func (c *Conn) handleUserhost(
|
||||
strings.Join(replies, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// handleVersion replies with the server version string.
|
||||
func (c *Conn) handleVersion() {
|
||||
c.sendNumeric(
|
||||
irc.RplVersion,
|
||||
c.svc.ServerVersion()+".", c.serverSfx,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
// handleAdmin replies with server admin info.
|
||||
func (c *Conn) handleAdmin() {
|
||||
srvName := c.serverSfx
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminMe,
|
||||
srvName, "Administrative info",
|
||||
)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminLoc1,
|
||||
"neoirc server",
|
||||
)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminLoc2,
|
||||
"IRC over HTTP",
|
||||
)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminEmail,
|
||||
"admin@"+srvName,
|
||||
)
|
||||
}
|
||||
|
||||
// handleInfo replies with server software info.
|
||||
func (c *Conn) handleInfo() {
|
||||
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 current time.
|
||||
func (c *Conn) handleTime() {
|
||||
srvName := c.serverSfx
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplTime,
|
||||
srvName, time.Now().Format(time.RFC1123),
|
||||
)
|
||||
}
|
||||
|
||||
// handleKillCmd forcibly disconnects a target user (oper
|
||||
// only).
|
||||
func (c *Conn) handleKillCmd(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
isOper, err := c.database.IsSessionOper(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoPrivileges,
|
||||
"Permission Denied- "+
|
||||
"You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
"KILL", "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetNick := msg.Params[0]
|
||||
|
||||
reason := "KILLed"
|
||||
if len(msg.Params) > 1 {
|
||||
reason = msg.Params[1]
|
||||
}
|
||||
|
||||
killerNick := c.currentNick()
|
||||
|
||||
if strings.EqualFold(targetNick, killerNick) {
|
||||
c.sendNumeric(
|
||||
irc.ErrCantKillServer,
|
||||
"You cannot KILL yourself",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetSID, lookupErr := c.database.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
)
|
||||
if lookupErr != nil {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoSuchNick,
|
||||
targetNick, "No such nick/channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
quitReason := "Killed (" + killerNick +
|
||||
" (" + reason + "))"
|
||||
|
||||
// KillSession broadcasts the QUIT, deletes the session
|
||||
// and disconnects the victim's wire connection.
|
||||
c.svc.KillSession(
|
||||
ctx, targetSID, targetNick, quitReason,
|
||||
)
|
||||
}
|
||||
|
||||
// handleWallopsCmd broadcasts to all +w users (oper only).
|
||||
func (c *Conn) handleWallopsCmd(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
isOper, err := c.database.IsSessionOper(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoPrivileges,
|
||||
"Permission Denied- "+
|
||||
"You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
"WALLOPS", "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
message := msg.Params[0]
|
||||
|
||||
wallopsSIDs, wallErr := c.database.
|
||||
GetWallopsSessionIDs(ctx)
|
||||
if wallErr != nil {
|
||||
c.log.Error(
|
||||
"get wallops sessions failed",
|
||||
"error", wallErr,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(wallopsSIDs) > 0 {
|
||||
body, mErr := json.Marshal([]string{message})
|
||||
if mErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _, _ = c.svc.FanOut(
|
||||
ctx, irc.CmdWallops, c.currentNick(), "*",
|
||||
nil, body, nil, wallopsSIDs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+115
-2
@@ -22,6 +22,7 @@ const (
|
||||
maxLineLen = 512
|
||||
readTimeout = 5 * time.Minute
|
||||
writeTimeout = 30 * time.Second
|
||||
killWriteWindow = 2 * time.Second
|
||||
dnsTimeout = 3 * time.Second
|
||||
pollInterval = 100 * time.Millisecond
|
||||
pingInterval = 90 * time.Second
|
||||
@@ -46,6 +47,12 @@ type Conn struct {
|
||||
serverSfx string
|
||||
commands map[string]cmdHandler
|
||||
|
||||
// writeMu serializes writes to conn. A connection is
|
||||
// written to by its own read loop, by its relay
|
||||
// goroutine, and — when an operator KILLs it — by
|
||||
// another client's goroutine.
|
||||
writeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
username string
|
||||
@@ -62,6 +69,7 @@ type Conn struct {
|
||||
|
||||
lastQueueID int64
|
||||
closed bool
|
||||
killed bool
|
||||
}
|
||||
|
||||
func newConn(
|
||||
@@ -97,6 +105,57 @@ func newConn(
|
||||
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 c.conn.Close() //nolint:errcheck
|
||||
|
||||
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
|
||||
// to handler functions.
|
||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
@@ -130,6 +189,12 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
c.handleCAP(msg)
|
||||
},
|
||||
"USERHOST": 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.handleKillCmd,
|
||||
irc.CmdWallops: c.handleWallopsCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +245,7 @@ func (c *Conn) serve(ctx context.Context) {
|
||||
|
||||
c.handleMessage(ctx, msg)
|
||||
|
||||
if c.closed {
|
||||
if c.isClosed() {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -189,24 +254,65 @@ func (c *Conn) serve(ctx context.Context) {
|
||||
func (c *Conn) cleanup(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
wasRegistered := c.registered
|
||||
wasKilled := c.killed
|
||||
sessID := c.sessionID
|
||||
nick := c.nick
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if wasRegistered && sessID > 0 {
|
||||
c.svc.UnregisterWireConn(sessID, c)
|
||||
|
||||
// A KILLed session has already been broadcast and
|
||||
// deleted by the killer; broadcasting again would
|
||||
// fan out a QUIT for a session row that is gone.
|
||||
if !wasKilled {
|
||||
c.svc.BroadcastQuit(
|
||||
ctx, sessID, nick, "Connection closed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.conn.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
// isClosed reports whether the connection has been marked
|
||||
// for teardown, either by QUIT or by an operator KILL.
|
||||
func (c *Conn) isClosed() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.closed
|
||||
}
|
||||
|
||||
// currentNick returns the connection's registered nick.
|
||||
// c.nick is written under c.mu during registration and
|
||||
// NICK changes, so every read must take the mutex.
|
||||
func (c *Conn) currentNick() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.nick
|
||||
}
|
||||
|
||||
// send writes a formatted IRC line to the connection.
|
||||
func (c *Conn) send(line string) {
|
||||
c.sendWithin(writeTimeout, line)
|
||||
}
|
||||
|
||||
// sendWithin writes a formatted IRC line to the connection
|
||||
// under the given write deadline. Callers that must not be
|
||||
// held hostage by an unresponsive peer pass a shorter window
|
||||
// than writeTimeout.
|
||||
func (c *Conn) sendWithin(
|
||||
timeout time.Duration,
|
||||
line string,
|
||||
) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
_ = c.conn.SetWriteDeadline(
|
||||
time.Now().Add(writeTimeout),
|
||||
time.Now().Add(timeout),
|
||||
)
|
||||
|
||||
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||
@@ -387,7 +493,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
||||
"failed to create session", "error", err,
|
||||
)
|
||||
c.send("ERROR :Internal server error")
|
||||
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -398,6 +507,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
||||
c.registered = true
|
||||
c.mu.Unlock()
|
||||
|
||||
// Make this connection reachable by session ID so that
|
||||
// KILL from either transport can disconnect it.
|
||||
c.svc.RegisterWireConn(sessionID, c)
|
||||
|
||||
// If PASS was provided before registration, set the
|
||||
// session password.
|
||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package ircserver_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||
)
|
||||
|
||||
// disconnectBudget is how long Disconnect is allowed to
|
||||
// take when the victim never reads. It is far below the
|
||||
// 30s writeTimeout that the old synchronous implementation
|
||||
// would have burned on each of its two writes.
|
||||
const disconnectBudget = 2 * time.Second
|
||||
|
||||
// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves
|
||||
// that an operator KILL cannot be stalled by its target.
|
||||
// The victim's socket is a net.Pipe, so every write blocks
|
||||
// until the peer reads and the peer here never does. The
|
||||
// old implementation performed both notification writes on
|
||||
// the killer's goroutine, which wedged the operator's own
|
||||
// serve() loop on the IRC path and the API request on the
|
||||
// HTTP path for as long as the victim cared to stay silent.
|
||||
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
serverSide, clientSide := net.Pipe()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = clientSide.Close()
|
||||
})
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||
))
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
}
|
||||
|
||||
victim := ircserver.NewTestConn(
|
||||
log, cfg, serverSide, "victim",
|
||||
)
|
||||
|
||||
returned := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
victim.Disconnect("killed by oper")
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(disconnectBudget):
|
||||
t.Fatal(
|
||||
"Disconnect blocked on the victim's socket; " +
|
||||
"the killer must not be held hostage",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectNotifiesAndClosesVictim is the other half
|
||||
// of the contract: moving the notification off the killer's
|
||||
// goroutine must not lose it. A victim that does read gets
|
||||
// both the KILL and the ERROR line, and then its socket is
|
||||
// closed so its read loop unblocks.
|
||||
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverSide, clientSide := net.Pipe()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = clientSide.Close()
|
||||
})
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||
))
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
}
|
||||
|
||||
victim := ircserver.NewTestConn(
|
||||
log, cfg, serverSide, "victim",
|
||||
)
|
||||
|
||||
lines := make(chan []string, 1)
|
||||
|
||||
go func() {
|
||||
var got []string
|
||||
|
||||
scanner := bufio.NewScanner(clientSide)
|
||||
for scanner.Scan() {
|
||||
got = append(got, scanner.Text())
|
||||
}
|
||||
|
||||
lines <- got
|
||||
}()
|
||||
|
||||
victim.Disconnect("killed by oper")
|
||||
|
||||
var got []string
|
||||
|
||||
select {
|
||||
case got = <-lines:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("victim socket was never closed")
|
||||
}
|
||||
|
||||
joined := strings.Join(got, "\n")
|
||||
|
||||
if !strings.Contains(joined, "KILL victim") {
|
||||
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"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
)
|
||||
|
||||
@@ -19,8 +21,14 @@ func NewTestServer(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
) *Server {
|
||||
globs := &globals.Globals{
|
||||
Appname: "neoirc",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
svc := service.NewTestService(
|
||||
database, brk, cfg, log,
|
||||
database, brk, cfg, globs, log,
|
||||
)
|
||||
|
||||
return &Server{ //nolint:exhaustruct
|
||||
@@ -47,3 +55,21 @@ func (s *Server) Stop() {
|
||||
func (s *Server) Listener() net.Listener {
|
||||
return s.listener
|
||||
}
|
||||
|
||||
// NewTestConn wraps an already-established net.Conn in a
|
||||
// Conn so tests can drive connection-level behaviour such
|
||||
// as Disconnect without standing up a whole server.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -762,6 +762,336 @@ 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")
|
||||
|
||||
// Query single nick.
|
||||
alice.send("USERHOST bob")
|
||||
|
||||
aliceReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 302 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceReply, " 302 ",
|
||||
"RPL_USERHOST",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, "bob",
|
||||
"USERHOST contains queried nick",
|
||||
)
|
||||
|
||||
// Query multiple nicks.
|
||||
bob.send("USERHOST alice bob")
|
||||
|
||||
bobReply := bob.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 302 ")
|
||||
})
|
||||
assertContains(
|
||||
t, bobReply, " 302 ",
|
||||
"RPL_USERHOST multi-nick",
|
||||
)
|
||||
assertContains(
|
||||
t, bobReply, "alice",
|
||||
"USERHOST multi contains alice",
|
||||
)
|
||||
assertContains(
|
||||
t, bobReply, "bob",
|
||||
"USERHOST multi contains bob",
|
||||
)
|
||||
}
|
||||
|
||||
// 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, "test.irc",
|
||||
"TIME reply includes server name",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationKill verifies the KILL command: oper can
|
||||
// kill a user, non-oper cannot, and — most importantly —
|
||||
// that the victim is actually disconnected rather than
|
||||
// merely announced as having quit.
|
||||
//
|
||||
//nolint:funlen // one KILL scenario asserted end to end
|
||||
func TestIntegrationKill(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithOper(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
// Both join a channel so KILL's QUIT is visible.
|
||||
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")
|
||||
})
|
||||
|
||||
// Non-oper KILL should fail.
|
||||
alice.send("KILL bob :nope")
|
||||
|
||||
aliceKillFail := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 481 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceKillFail, " 481 ",
|
||||
"ERR_NOPRIVILEGES for non-oper KILL",
|
||||
)
|
||||
|
||||
// alice becomes oper.
|
||||
alice.send("OPER testoper testpass")
|
||||
alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 381 ")
|
||||
})
|
||||
|
||||
// Oper KILL should succeed.
|
||||
alice.send("KILL bob :bad behavior")
|
||||
|
||||
// The victim must be told why and then disconnected.
|
||||
// Reading to EOF is the assertion that matters: a KILL
|
||||
// that only broadcasts a QUIT leaves bob holding a
|
||||
// socket that looks alive but delivers nothing.
|
||||
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",
|
||||
)
|
||||
|
||||
// KILL nonexistent nick.
|
||||
alice.send("KILL nobody123 :gone")
|
||||
|
||||
aliceNoSuch := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 401 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceNoSuch, " 401 ",
|
||||
"ERR_NOSUCHNICK for KILL missing target",
|
||||
)
|
||||
}
|
||||
|
||||
// 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 becomes oper.
|
||||
alice.send("OPER testoper testpass")
|
||||
alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 381 ")
|
||||
})
|
||||
|
||||
// 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",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationModeSecret tests +s (secret) channel
|
||||
// mode — verifies that +s can be set and the mode is
|
||||
// reflected in MODE queries.
|
||||
@@ -915,3 +1245,80 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
|
||||
"carol receives trio message",
|
||||
)
|
||||
}
|
||||
|
||||
// assertNoEmptyParam fails if any line contains a doubled
|
||||
// space, which is what FormatMessage emits for an empty
|
||||
// non-trailing parameter. A numeric whose server-name
|
||||
// parameter came out empty is malformed on the wire even
|
||||
// though it still contains the numeric code, so the other
|
||||
// assertions in this file would not catch it.
|
||||
func assertNoEmptyParam(
|
||||
t *testing.T,
|
||||
lines []string,
|
||||
context string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, " ") {
|
||||
t.Errorf(
|
||||
"%s: empty parameter in wire line: %q",
|
||||
context, line,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationDefaultServerNameFallback runs the wire
|
||||
// server with SERVER_NAME unset, which is the shipped
|
||||
// default from config.go, and verifies that VERSION, ADMIN
|
||||
// and TIME fall back to "neoirc" exactly as the HTTP path
|
||||
// does instead of emitting an empty server-name parameter.
|
||||
//
|
||||
// Every other wire test hardcodes ServerName: "test.irc",
|
||||
// so none of them exercise the default configuration.
|
||||
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithServerName(t, "")
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
alice.send("VERSION")
|
||||
|
||||
versionReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 351 ")
|
||||
})
|
||||
assertNoEmptyParam(t, versionReply, "VERSION")
|
||||
assertContains(
|
||||
t, versionReply, "neoirc",
|
||||
"VERSION falls back to default server name",
|
||||
)
|
||||
|
||||
alice.send("ADMIN")
|
||||
|
||||
adminReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 259 ")
|
||||
})
|
||||
assertNoEmptyParam(t, adminReply, "ADMIN")
|
||||
assertContains(
|
||||
t, adminReply, " 256 alice neoirc ",
|
||||
"RPL_ADMINME names the default server",
|
||||
)
|
||||
assertContains(
|
||||
t, adminReply, "admin@neoirc",
|
||||
"RPL_ADMINEMAIL is a well-formed address",
|
||||
)
|
||||
|
||||
alice.send("TIME")
|
||||
|
||||
timeReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 391 ")
|
||||
})
|
||||
assertNoEmptyParam(t, timeReply, "TIME")
|
||||
assertContains(
|
||||
t, timeReply, " 391 alice neoirc ",
|
||||
"RPL_TIME names the default server",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
|
||||
c.deliverKickMsg(msg, text)
|
||||
case command == "INVITE":
|
||||
c.deliverInviteMsg(msg, text)
|
||||
case command == irc.CmdWallops:
|
||||
c.deliverWallops(msg, text)
|
||||
case command == irc.CmdMode:
|
||||
c.deliverMode(msg, text)
|
||||
case command == irc.CmdPing:
|
||||
@@ -337,6 +339,18 @@ func (c *Conn) deliverInviteMsg(
|
||||
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.
|
||||
func (c *Conn) deliverMode(
|
||||
msg *db.IRCMessage,
|
||||
|
||||
@@ -39,6 +39,101 @@ type testEnv struct {
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
return newTestEnvWithServerName(t, "test.irc")
|
||||
}
|
||||
|
||||
// newTestEnvWithServerName creates a test environment with
|
||||
// an explicit SERVER_NAME. Passing "" exercises the shipped
|
||||
// default from config.go, under which the server must fall
|
||||
// back to "neoirc" rather than emitting an empty
|
||||
// server-name parameter.
|
||||
func newTestEnvWithServerName(
|
||||
t *testing.T,
|
||||
serverName string,
|
||||
) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||
t.Name(),
|
||||
)
|
||||
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
_, err = conn.ExecContext(
|
||||
t.Context(), "PRAGMA foreign_keys = ON",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pragma: %v", err)
|
||||
}
|
||||
|
||||
database := db.NewTestDatabaseFromConn(conn)
|
||||
|
||||
err = database.RunMigrations(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: serverName,
|
||||
MOTD: "Welcome to test IRC",
|
||||
}
|
||||
|
||||
var listenConfig net.ListenConfig
|
||||
|
||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
addr := listener.Addr().String()
|
||||
|
||||
err = listener.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||
))
|
||||
|
||||
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||
|
||||
err = srv.Start(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("start irc server: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
srv.Stop()
|
||||
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
t.Logf("close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &testEnv{
|
||||
database: database,
|
||||
brk: brk,
|
||||
cfg: cfg,
|
||||
srv: srv,
|
||||
}
|
||||
}
|
||||
|
||||
// newTestEnvWithOper creates a test environment with oper
|
||||
// credentials configured.
|
||||
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||
t.Name(),
|
||||
@@ -70,11 +165,11 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
MOTD: "Welcome to test IRC",
|
||||
OperName: "testoper",
|
||||
OperPassword: "testpass",
|
||||
}
|
||||
|
||||
var listenConfig net.ListenConfig
|
||||
|
||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
@@ -215,6 +310,36 @@ func (tc *testClient) register(nick string) []string {
|
||||
})
|
||||
}
|
||||
|
||||
// readUntilClosed reads until the peer closes the
|
||||
// connection, returning the lines received first. It
|
||||
// fails the test if the connection is still open when the
|
||||
// read deadline expires, which is what a KILL that never
|
||||
// terminates the victim's socket looks like.
|
||||
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(
|
||||
"expected EOF on victim socket, got %v "+
|
||||
"(lines: %v)",
|
||||
err, lines,
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// assertContains checks that at least one line matches the
|
||||
// given substring.
|
||||
func assertContains(
|
||||
@@ -233,6 +358,27 @@ func assertContains(
|
||||
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
|
||||
// RPL_ENDOFNAMES.
|
||||
func (tc *testClient) joinAndDrain(channel string) {
|
||||
|
||||
+310
-2
@@ -10,11 +10,14 @@ import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
@@ -33,6 +36,18 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Broker *broker.Broker
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// WireConn is a live client connection that a transport
|
||||
// registers with the service so that commands such as KILL
|
||||
// can reach it. The IRC wire server registers one per
|
||||
// registered connection; the HTTP transport has no
|
||||
// long-lived socket and registers nothing.
|
||||
type WireConn interface {
|
||||
// Disconnect terminates the connection, telling the
|
||||
// client why before closing the socket.
|
||||
Disconnect(reason string)
|
||||
}
|
||||
|
||||
// Service provides shared business logic for IRC commands.
|
||||
@@ -40,16 +55,22 @@ type Service struct {
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
config *config.Config
|
||||
globals *globals.Globals
|
||||
log *slog.Logger
|
||||
|
||||
wireMu sync.Mutex
|
||||
wireConns map[int64]WireConn
|
||||
}
|
||||
|
||||
// New creates a new Service.
|
||||
func New(params Params) *Service {
|
||||
return &Service{
|
||||
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
globals: params.Globals,
|
||||
log: params.Logger.Get(),
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +80,105 @@ func NewTestService(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
cfg *config.Config,
|
||||
globs *globals.Globals,
|
||||
log *slog.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
globals: globs,
|
||||
log: log,
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
// ServerVersion returns the canonical server version string
|
||||
// used by every transport, e.g. "neoirc-1.2.3". Both the
|
||||
// IRC wire protocol and the HTTP API must report the same
|
||||
// string, so this is the only place it is built.
|
||||
func (s *Service) ServerVersion() string {
|
||||
name := "neoirc"
|
||||
ver := "dev"
|
||||
|
||||
if s.globals != nil {
|
||||
if s.globals.Appname != "" {
|
||||
name = s.globals.Appname
|
||||
}
|
||||
|
||||
if s.globals.Version != "" {
|
||||
ver = s.globals.Version
|
||||
}
|
||||
}
|
||||
|
||||
return name + "-" + ver
|
||||
}
|
||||
|
||||
// InfoLines returns the RPL_INFO body. Both transports
|
||||
// send exactly these lines so that INFO does not diverge
|
||||
// between the wire protocol and the HTTP API.
|
||||
func (s *Service) InfoLines() []string {
|
||||
started := "unknown"
|
||||
if s.globals != nil && !s.globals.StartTime.IsZero() {
|
||||
started = s.globals.StartTime.Format(time.RFC1123)
|
||||
}
|
||||
|
||||
return []string{
|
||||
"neoirc — IRC semantics over HTTP",
|
||||
"Version: " + s.ServerVersion(),
|
||||
"Written in Go",
|
||||
"Started: " + started,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWireConn associates a live wire connection with
|
||||
// its session ID so that KillSession can reach it.
|
||||
func (s *Service) RegisterWireConn(
|
||||
sessionID int64,
|
||||
conn WireConn,
|
||||
) {
|
||||
s.wireMu.Lock()
|
||||
defer s.wireMu.Unlock()
|
||||
|
||||
s.wireConns[sessionID] = conn
|
||||
}
|
||||
|
||||
// UnregisterWireConn removes the association created by
|
||||
// RegisterWireConn. It is a no-op if the session has
|
||||
// already been rebound to a different 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)
|
||||
}
|
||||
}
|
||||
|
||||
// KillSession forcibly removes a user from the server: the
|
||||
// victim's channel peers are told via QUIT, the victim's
|
||||
// session is deleted, and any live wire connection it holds
|
||||
// is disconnected. Both the IRC KILL command and the HTTP
|
||||
// KILL endpoint route through here so the two transports
|
||||
// cannot diverge.
|
||||
func (s *Service) KillSession(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, reason string,
|
||||
) {
|
||||
s.BroadcastQuit(ctx, sessionID, nick, reason)
|
||||
|
||||
// A session with no registered wire connection (an
|
||||
// HTTP-only client) has nothing left to disconnect.
|
||||
s.wireMu.Lock()
|
||||
conn := s.wireConns[sessionID]
|
||||
s.wireMu.Unlock()
|
||||
|
||||
if conn != nil {
|
||||
conn.Disconnect(reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +905,201 @@ func (s *Service) QueryChannelMode(
|
||||
return modes + modeParams
|
||||
}
|
||||
|
||||
// QueryUserMode returns the current user mode string for
|
||||
// the given session (e.g. "+ow", "+w", "+"). A database
|
||||
// failure is returned rather than being reported as an
|
||||
// unset flag: an unreadable mode is not the same as an
|
||||
// absent one, and reporting "+" for either would tell the
|
||||
// user they are de-opered when the truth is unknown.
|
||||
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
|
||||
}
|
||||
|
||||
// userModeOp is a single parsed user-mode change collected
|
||||
// by parseUserModeString before any DB writes happen.
|
||||
type userModeOp struct {
|
||||
char rune
|
||||
adding bool
|
||||
}
|
||||
|
||||
// ApplyUserMode parses an IRC user-mode string and applies
|
||||
// the resulting changes atomically. It supports multiple
|
||||
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
|
||||
// rejects malformed input (empty string, no leading sign,
|
||||
// bare sign with no mode letters, unknown mode letters,
|
||||
// +o which must be set via OPER) with an IRCError. On
|
||||
// failure, no persistent change is made: parsing happens
|
||||
// before any write, and the writes themselves run inside a
|
||||
// single database transaction that is rolled back whole if
|
||||
// any statement fails. On success, the resulting mode
|
||||
// string is returned.
|
||||
func (s *Service) ApplyUserMode(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
modeStr string,
|
||||
) (string, error) {
|
||||
ops, err := parseUserModeString(modeStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
wallops, oper, err := collapseUserModeOps(ops)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.db.SetSessionUserModes(
|
||||
ctx, sessionID, wallops, oper,
|
||||
); err != nil {
|
||||
s.log.Error(
|
||||
"apply user modes failed", "error", err,
|
||||
)
|
||||
|
||||
return "", fmt.Errorf("apply user modes: %w", err)
|
||||
}
|
||||
|
||||
return s.QueryUserMode(ctx, sessionID)
|
||||
}
|
||||
|
||||
// parseUserModeString validates and parses a user-mode
|
||||
// string into a list of operations. The string must begin
|
||||
// with '+' or '-'; subsequent '+' / '-' characters flip the
|
||||
// active sign, and letters between them are applied with
|
||||
// the current sign. Every letter must be a recognized user
|
||||
// mode for this server, and '+o' is never allowed via MODE
|
||||
// (use OPER to become operator). If any character is
|
||||
// invalid, no operations are returned and an IRCError with
|
||||
// ERR_UMODEUNKNOWNFLAG (501) is returned.
|
||||
func parseUserModeString(
|
||||
modeStr string,
|
||||
) ([]userModeOp, error) {
|
||||
unknownFlag := &IRCError{
|
||||
Code: irc.ErrUmodeUnknownFlag,
|
||||
Params: nil,
|
||||
Message: "Unknown MODE flag",
|
||||
}
|
||||
|
||||
if modeStr == "" {
|
||||
return nil, unknownFlag
|
||||
}
|
||||
|
||||
first := modeStr[0]
|
||||
if first != '+' && first != '-' {
|
||||
return nil, unknownFlag
|
||||
}
|
||||
|
||||
ops := make([]userModeOp, 0, len(modeStr)-1)
|
||||
adding := true
|
||||
|
||||
for _, modeChar := range modeStr {
|
||||
switch modeChar {
|
||||
case '+':
|
||||
adding = true
|
||||
case '-':
|
||||
adding = false
|
||||
default:
|
||||
if !isKnownUserModeChar(modeChar) {
|
||||
return nil, unknownFlag
|
||||
}
|
||||
|
||||
if modeChar == 'o' && adding {
|
||||
return nil, unknownFlag
|
||||
}
|
||||
|
||||
ops = append(ops, userModeOp{
|
||||
char: modeChar, adding: adding,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(ops) == 0 {
|
||||
return nil, unknownFlag
|
||||
}
|
||||
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// isKnownUserModeChar reports whether the character is a
|
||||
// recognized user mode letter.
|
||||
func isKnownUserModeChar(modeChar rune) bool {
|
||||
switch modeChar {
|
||||
case 'w', 'o':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// collapseUserModeOps reduces an already-parsed operation
|
||||
// list to the final desired value of each user mode flag.
|
||||
// A nil result for a flag means the mode string never
|
||||
// mentioned it, so it must be left untouched. Later
|
||||
// operations win over earlier ones for the same letter
|
||||
// (e.g. "+w-w" ends with wallops off), which matches the
|
||||
// left-to-right semantics of applying each op in turn.
|
||||
// parseUserModeString must have validated every character
|
||||
// and sign before this runs; the default branch here is
|
||||
// defence-in-depth only.
|
||||
func collapseUserModeOps(
|
||||
ops []userModeOp,
|
||||
) (*bool, *bool, error) {
|
||||
unknownFlag := &IRCError{
|
||||
Code: irc.ErrUmodeUnknownFlag,
|
||||
Params: nil,
|
||||
Message: "Unknown MODE flag",
|
||||
}
|
||||
|
||||
var wallops, oper *bool
|
||||
|
||||
for _, modeOp := range ops {
|
||||
switch modeOp.char {
|
||||
case 'w':
|
||||
val := modeOp.adding
|
||||
wallops = &val
|
||||
case 'o':
|
||||
if modeOp.adding {
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
|
||||
val := false
|
||||
oper = &val
|
||||
default:
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
}
|
||||
|
||||
return wallops, oper, nil
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
// change.
|
||||
func (s *Service) broadcastNickChange(
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
@@ -55,9 +56,10 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
func() *globals.Globals {
|
||||
return &globals.Globals{ //nolint:exhaustruct
|
||||
return &globals.Globals{
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
},
|
||||
logger.New,
|
||||
@@ -363,3 +365,450 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||
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")
|
||||
|
||||
// Fresh session has no modes.
|
||||
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)
|
||||
}
|
||||
|
||||
// Set wallops.
|
||||
_ = env.db.SetSessionWallops(ctx, sid, 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)
|
||||
}
|
||||
|
||||
// Set oper.
|
||||
_ = env.db.SetSessionOper(ctx, sid, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// applyUserModeCaseState is the subset of session user-mode
|
||||
// state the rigorous TestApplyUserMode suite asserts on. It
|
||||
// mirrors the columns (oper, wallops) that the parser is
|
||||
// permitted to mutate.
|
||||
type applyUserModeCaseState struct {
|
||||
oper bool
|
||||
wallops bool
|
||||
}
|
||||
|
||||
// applyUserModeCase describes one rigor-suite case for
|
||||
// Service.ApplyUserMode: the pre-call DB state, the mode
|
||||
// string input, and the expected post-call observable state
|
||||
// (mode string on success, IRC error code on rejection, and
|
||||
// persisted session state either way).
|
||||
type applyUserModeCase struct {
|
||||
name string
|
||||
initialState applyUserModeCaseState
|
||||
modeStr string
|
||||
wantModes string
|
||||
wantErr bool
|
||||
wantErrCode irc.IRCMessageType
|
||||
wantState applyUserModeCaseState
|
||||
}
|
||||
|
||||
// applyUserModeCases returns every case listed in sneak's
|
||||
// review of PR #96 plus a few adjacent ones. Split across
|
||||
// helpers by category so each stays under funlen.
|
||||
func applyUserModeCases() []applyUserModeCase {
|
||||
cases := applyUserModeHappyPathCases()
|
||||
cases = append(cases, applyUserModeSignTransitionCases()...)
|
||||
cases = append(cases, applyUserModeMalformedCases()...)
|
||||
cases = append(cases, applyUserModeUnknownLetterCases()...)
|
||||
|
||||
return cases
|
||||
}
|
||||
|
||||
// applyUserModeHappyPathCases covers valid single-char and
|
||||
// multi-char-without-sign-transition mode operations.
|
||||
func applyUserModeHappyPathCases() []applyUserModeCase {
|
||||
return []applyUserModeCase{
|
||||
{
|
||||
name: "+w from empty",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+w",
|
||||
wantModes: "+w",
|
||||
wantErr: false,
|
||||
wantErrCode: 0,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||
},
|
||||
{
|
||||
name: "-w from +w",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
||||
modeStr: "-w",
|
||||
wantModes: "+",
|
||||
wantErr: false,
|
||||
wantErrCode: 0,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "-o from +o",
|
||||
initialState: applyUserModeCaseState{oper: true, wallops: false},
|
||||
modeStr: "-o",
|
||||
wantModes: "+",
|
||||
wantErr: false,
|
||||
wantErrCode: 0,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "-wo from +ow",
|
||||
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
||||
modeStr: "-wo",
|
||||
wantModes: "+",
|
||||
wantErr: false,
|
||||
wantErrCode: 0,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyUserModeSignTransitionCases covers multi-char mode
|
||||
// strings where '+' and '-' flip partway through. +o is
|
||||
// never legal via MODE, so strings containing it must be
|
||||
// rejected atomically.
|
||||
func applyUserModeSignTransitionCases() []applyUserModeCase {
|
||||
return []applyUserModeCase{
|
||||
{
|
||||
name: "+w-o from +o",
|
||||
initialState: applyUserModeCaseState{oper: true, wallops: false},
|
||||
modeStr: "+w-o",
|
||||
wantModes: "+w",
|
||||
wantErr: false,
|
||||
wantErrCode: 0,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||
},
|
||||
{
|
||||
// +o is rejected before any op applies; wallops
|
||||
// stays set.
|
||||
name: "-w+o always rejects +o",
|
||||
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
||||
modeStr: "-w+o",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: true, wallops: true},
|
||||
},
|
||||
{
|
||||
// Wallops must NOT be cleared; oper must NOT be
|
||||
// cleared. Rejection is fully atomic.
|
||||
name: "+o-w+w rejects because of +o",
|
||||
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
||||
modeStr: "+o-w+w",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: true, wallops: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyUserModeMalformedCases covers inputs that lack a
|
||||
// leading '+' or '-' and inputs that consist of bare signs
|
||||
// without mode letters. All must be rejected with no side
|
||||
// effects.
|
||||
func applyUserModeMalformedCases() []applyUserModeCase {
|
||||
return []applyUserModeCase{
|
||||
{
|
||||
name: "w no prefix rejects",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "w",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
// Prove wallops is NOT cleared — the whole point
|
||||
// of sneak's review.
|
||||
name: "xw no prefix rejects (would have been" +
|
||||
" silently -w before)",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
||||
modeStr: "xw",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||
},
|
||||
{
|
||||
name: "empty string rejects",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "bare + rejects",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "bare - rejects",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "-",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "+-+ rejects (no mode letters)",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+-+",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyUserModeUnknownLetterCases covers well-formed prefix
|
||||
// strings that contain unknown mode letters. Rejection must
|
||||
// be atomic: any valid letters before the invalid one must
|
||||
// not persist.
|
||||
func applyUserModeUnknownLetterCases() []applyUserModeCase {
|
||||
return []applyUserModeCase{
|
||||
{
|
||||
name: "-x+y rejects unknown -x",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "-x+y",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "+y-x rejects unknown +y",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+y-x",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "+z unknown mode rejects",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+z",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
// Wallops must NOT be set.
|
||||
name: "+wz rejects whole thing; +w side effect" +
|
||||
" must NOT persist",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+wz",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
{
|
||||
name: "+wo rejects whole thing; +w side effect" +
|
||||
" must NOT persist",
|
||||
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
modeStr: "+wo",
|
||||
wantModes: "",
|
||||
wantErr: true,
|
||||
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyUserMode is the rigorous table-driven suite for
|
||||
// the shared user-mode parser. It covers every case listed
|
||||
// in sneak's review of PR #96 plus a few adjacent ones.
|
||||
// Each case asserts the resulting mode string AND the
|
||||
// persisted session state, to prove that rejected input
|
||||
// leaves no side effects.
|
||||
func TestApplyUserMode(t *testing.T) {
|
||||
for _, testCase := range applyUserModeCases() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
runApplyUserModeCase(t, testCase)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runApplyUserModeCase executes one applyUserModeCase: seed
|
||||
// the session state, invoke ApplyUserMode, and verify both
|
||||
// the returned value and the post-call persisted state.
|
||||
func runApplyUserModeCase(
|
||||
t *testing.T, testCase applyUserModeCase,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
sid := createSession(ctx, t, env.db, "alice")
|
||||
|
||||
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
|
||||
|
||||
result, err := env.svc.ApplyUserMode(
|
||||
ctx, sid, testCase.modeStr,
|
||||
)
|
||||
|
||||
verifyApplyUserModeOutcome(t, testCase, result, err)
|
||||
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
|
||||
}
|
||||
|
||||
// seedApplyUserModeState installs the pre-call session
|
||||
// state described by initialState.
|
||||
func seedApplyUserModeState(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
sid int64,
|
||||
initialState applyUserModeCaseState,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if initialState.oper {
|
||||
if err := database.SetSessionOper(
|
||||
ctx, sid, true,
|
||||
); err != nil {
|
||||
t.Fatalf("init oper: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if initialState.wallops {
|
||||
if err := database.SetSessionWallops(
|
||||
ctx, sid, true,
|
||||
); err != nil {
|
||||
t.Fatalf("init wallops: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyApplyUserModeOutcome asserts the direct return
|
||||
// value of ApplyUserMode. It dispatches to the error- or
|
||||
// success-specific verifier based on wantErr.
|
||||
func verifyApplyUserModeOutcome(
|
||||
t *testing.T,
|
||||
testCase applyUserModeCase,
|
||||
result string,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if testCase.wantErr {
|
||||
verifyApplyUserModeError(t, testCase, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
verifyApplyUserModeSuccess(t, testCase, result, err)
|
||||
}
|
||||
|
||||
// verifyApplyUserModeError checks that err is a
|
||||
// *service.IRCError whose code matches wantErrCode.
|
||||
func verifyApplyUserModeError(
|
||||
t *testing.T, testCase applyUserModeCase, err error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var ircErr *service.IRCError
|
||||
if !errors.As(err, &ircErr) {
|
||||
t.Fatalf("expected IRCError, got %v", err)
|
||||
}
|
||||
|
||||
if ircErr.Code != testCase.wantErrCode {
|
||||
t.Errorf(
|
||||
"code: want %d got %d",
|
||||
testCase.wantErrCode, ircErr.Code,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyApplyUserModeSuccess checks that err is nil and the
|
||||
// returned mode string matches wantModes.
|
||||
func verifyApplyUserModeSuccess(
|
||||
t *testing.T,
|
||||
testCase applyUserModeCase,
|
||||
result string,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != testCase.wantModes {
|
||||
t.Errorf(
|
||||
"modes: want %q got %q",
|
||||
testCase.wantModes, result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyApplyUserModeState asserts the post-call persisted
|
||||
// session state. This is the atomicity guarantee sneak
|
||||
// demanded: whether the call succeeded or was rejected, the
|
||||
// DB must match wantState exactly.
|
||||
func verifyApplyUserModeState(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
sid int64,
|
||||
wantState applyUserModeCaseState,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
gotOper, err := database.IsSessionOper(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("read oper: %v", err)
|
||||
}
|
||||
|
||||
gotWallops, err := database.IsSessionWallops(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("read wallops: %v", err)
|
||||
}
|
||||
|
||||
if gotOper != wantState.oper {
|
||||
t.Errorf(
|
||||
"oper: want %v got %v",
|
||||
wantState.oper, gotOper,
|
||||
)
|
||||
}
|
||||
|
||||
if gotWallops != wantState.wallops {
|
||||
t.Errorf(
|
||||
"wallops: want %v got %v",
|
||||
wantState.wallops, gotWallops,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package irc
|
||||
|
||||
// IRC command names (RFC 1459 / RFC 2812).
|
||||
const (
|
||||
CmdAdmin = "ADMIN"
|
||||
CmdAway = "AWAY"
|
||||
CmdInfo = "INFO"
|
||||
CmdInvite = "INVITE"
|
||||
CmdJoin = "JOIN"
|
||||
CmdKick = "KICK"
|
||||
CmdKill = "KILL"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
CmdMode = "MODE"
|
||||
@@ -20,8 +23,12 @@ const (
|
||||
CmdPong = "PONG"
|
||||
CmdPrivmsg = "PRIVMSG"
|
||||
CmdQuit = "QUIT"
|
||||
CmdTime = "TIME"
|
||||
CmdTopic = "TOPIC"
|
||||
CmdUser = "USER"
|
||||
CmdUserhost = "USERHOST"
|
||||
CmdVersion = "VERSION"
|
||||
CmdWallops = "WALLOPS"
|
||||
CmdWho = "WHO"
|
||||
CmdWhois = "WHOIS"
|
||||
)
|
||||
|
||||
+4
-10
@@ -2,22 +2,16 @@
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Install esbuild if not present
|
||||
if ! command -v esbuild >/dev/null 2>&1; then
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
NPX="npx"
|
||||
else
|
||||
echo "esbuild not found. Install it: npm install -g esbuild"
|
||||
# The esbuild that yarn.lock pins, which yarn install puts in node_modules
|
||||
if [ ! -x node_modules/.bin/esbuild ]; then
|
||||
echo "esbuild not found. Install it: yarn install --frozen-lockfile"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
NPX=""
|
||||
fi
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
# 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 \
|
||||
--minify \
|
||||
--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