Compare commits
1
Commits
next
..
eb67f2b417
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb67f2b417 |
+7
-10
@@ -1,15 +1,9 @@
|
||||
# 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/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/src/ src/
|
||||
COPY web/build.sh build.sh
|
||||
RUN sh build.sh
|
||||
@@ -38,9 +32,12 @@ RUN go mod download
|
||||
COPY . .
|
||||
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
||||
# -p 4 because test runs on a shared build host cap their parallelism.
|
||||
RUN go test -p 4 -timeout 90s -race -cover ./... || \
|
||||
# -race also turns on Go's unsafe-pointer checks (checkptr) in every
|
||||
# package. In modernc.org, SQLite machine-translated from C, they doubled
|
||||
# the test time, so they are off there and stay on everywhere else.
|
||||
RUN go test -p 4 -timeout 90s -race -gcflags='modernc.org/...=-d=checkptr=0' -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -p 4 -timeout 90s -race -v ./...; exit 1; }
|
||||
go test -p 4 -timeout 90s -race -gcflags='modernc.org/...=-d=checkptr=0' -v ./...; exit 1; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies
|
||||
# are what make BuildKit build them first, so this stage cannot run
|
||||
|
||||
@@ -58,17 +58,3 @@ func (database *Database) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecForTest runs a raw statement, such as a trigger that
|
||||
// makes a write fail.
|
||||
func (database *Database) ExecForTest(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec for test: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+3
-194
@@ -7,7 +7,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -204,42 +203,21 @@ func (database *Database) GetSessionByToken(
|
||||
)
|
||||
}
|
||||
|
||||
_ = database.UpdateLastSeen(ctx, sessionID, clientID)
|
||||
|
||||
return sessionID, clientID, nick, nil
|
||||
}
|
||||
|
||||
// UpdateLastSeen sets last_seen to now on a session and on
|
||||
// one of its clients, so that the idle cleanup keeps them.
|
||||
func (database *Database) UpdateLastSeen(
|
||||
ctx context.Context,
|
||||
sessionID, clientID int64,
|
||||
) error {
|
||||
now := time.Now()
|
||||
|
||||
_, err := database.conn.ExecContext(
|
||||
_, _ = database.conn.ExecContext(
|
||||
ctx,
|
||||
"UPDATE sessions SET last_seen = ? WHERE id = ?",
|
||||
now, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"update session last_seen: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
_, err = database.conn.ExecContext(
|
||||
_, _ = database.conn.ExecContext(
|
||||
ctx,
|
||||
"UPDATE clients SET last_seen = ? WHERE id = ?",
|
||||
now, clientID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"update client last_seen: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
return sessionID, clientID, nick, nil
|
||||
}
|
||||
|
||||
// GetSessionByNick returns session id for a given nick.
|
||||
@@ -2445,172 +2423,3 @@ func (database *Database) SetChannelUserLimit(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionUserModes sets a session's wallops (+w) and
|
||||
// oper (+o) flags in one transaction, so that either both
|
||||
// changes are stored or neither is. A nil flag is left as
|
||||
// it is.
|
||||
func (database *Database) SetSessionUserModes(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
wallops *bool,
|
||||
oper *bool,
|
||||
) error {
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
if wallops != nil {
|
||||
_, err = transaction.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||
boolToInt(*wallops), sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf("set session wallops: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if oper != nil {
|
||||
_, err = transaction.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||
boolToInt(*oper), sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf("set session oper: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = transaction.Commit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit user modes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// boolToInt returns the 0 or 1 that the schema stores for a
|
||||
// boolean.
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsSessionWallops returns whether the session has the
|
||||
// wallops (+w) usermode set.
|
||||
func (database *Database) IsSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
var isWallops int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&isWallops)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check session wallops: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return isWallops != 0, nil
|
||||
}
|
||||
|
||||
// GetWallopsSessionIDs returns all session IDs that have
|
||||
// the wallops (+w) usermode set.
|
||||
func (database *Database) GetWallopsSessionIDs(
|
||||
ctx context.Context,
|
||||
) ([]int64, error) {
|
||||
rows, err := database.conn.QueryContext(
|
||||
ctx,
|
||||
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var ids []int64
|
||||
|
||||
for rows.Next() {
|
||||
var sessionID int64
|
||||
|
||||
err = rows.Scan(&sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"scan wallops session: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
ids = append(ids, sessionID)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"iterate wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UserhostInfo holds the data needed for RPL_USERHOST.
|
||||
type UserhostInfo struct {
|
||||
Nick string
|
||||
Username string
|
||||
Hostname string
|
||||
IsOper bool
|
||||
AwayMessage string
|
||||
}
|
||||
|
||||
// GetUserhostInfo returns USERHOST info for the given
|
||||
// nicks. Nicks with no session are left out.
|
||||
func (database *Database) GetUserhostInfo(
|
||||
ctx context.Context,
|
||||
nicks []string,
|
||||
) ([]UserhostInfo, error) {
|
||||
results := make([]UserhostInfo, 0, len(nicks))
|
||||
|
||||
for _, nick := range nicks {
|
||||
var info UserhostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT nick, username, hostname,
|
||||
is_oper, away_message
|
||||
FROM sessions WHERE nick = ?`,
|
||||
nick,
|
||||
).Scan(
|
||||
&info.Nick, &info.Username, &info.Hostname,
|
||||
&info.IsOper, &info.AwayMessage,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"userhost lookup %q: %w", nick, err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, info)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -1491,119 +1491,3 @@ func TestChannelUserLimit(t *testing.T) {
|
||||
t.Fatalf("expected 0, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSessionUserModesIsAtomic makes the is_oper write
|
||||
// fail after the is_wallops write has run, and checks that
|
||||
// the wallops change was rolled back.
|
||||
func TestSetSessionUserModesIsAtomic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, "alice", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.ExecForTest(ctx,
|
||||
`CREATE TRIGGER reject_oper
|
||||
BEFORE UPDATE OF is_oper ON sessions
|
||||
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
|
||||
END`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wallops := true
|
||||
oper := false
|
||||
|
||||
err = database.SetSessionUserModes(
|
||||
ctx, sessionID, &wallops, &oper,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected the rejected oper write to fail")
|
||||
}
|
||||
|
||||
gotWallops, err := database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if gotWallops {
|
||||
t.Error("wallops was stored although the change failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSessionUserModesAppliesBoth checks that both flags
|
||||
// are written, and that a nil flag is left as it is.
|
||||
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, "alice", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.SetSessionOper(ctx, sessionID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wallops := true
|
||||
oper := false
|
||||
|
||||
err = database.SetSessionUserModes(
|
||||
ctx, sessionID, &wallops, &oper,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotWallops, err := database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotOper, err := database.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !gotWallops || gotOper {
|
||||
t.Errorf(
|
||||
"want wallops=true oper=false, got %v/%v",
|
||||
gotWallops, gotOper,
|
||||
)
|
||||
}
|
||||
|
||||
err = database.SetSessionUserModes(
|
||||
ctx, sessionID, nil, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotWallops, err = database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !gotWallops {
|
||||
t.Error("a nil wallops flag cleared wallops")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 '',
|
||||
|
||||
+13
-31
@@ -1015,10 +1015,10 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
hdlr.handleQuit(
|
||||
writer, request, sessionID, nick, body,
|
||||
)
|
||||
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
|
||||
hdlr.dispatchOperCommand(
|
||||
case irc.CmdOper:
|
||||
hdlr.handleOper(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, command, bodyLines,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdMotd, irc.CmdPing:
|
||||
hdlr.dispatchInfoCommand(
|
||||
@@ -1075,27 +1075,6 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdUserhost:
|
||||
hdlr.handleUserhost(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdVersion:
|
||||
hdlr.handleVersion(
|
||||
writer, request, sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdAdmin:
|
||||
hdlr.handleAdmin(
|
||||
writer, request, sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdInfo:
|
||||
hdlr.handleInfo(
|
||||
writer, request, sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdTime:
|
||||
hdlr.handleTime(
|
||||
writer, request, sessionID, clientID, nick,
|
||||
)
|
||||
default:
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
@@ -1951,9 +1930,8 @@ func (hdlr *Handlers) handleQuit(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleMode handles the MODE command: a target that starts
|
||||
// with # is a channel, handled by handleChannelMode, and any
|
||||
// other target a nick, handled by handleUserMode.
|
||||
// handleMode handles the MODE command for channels and
|
||||
// users. Currently supports query-only (no mode changes).
|
||||
func (hdlr *Handlers) handleMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -1973,11 +1951,15 @@ func (hdlr *Handlers) handleMode(
|
||||
|
||||
channel := target
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
hdlr.handleUserMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target,
|
||||
bodyLines,
|
||||
// User mode query — return empty modes.
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplUmodeIs, nick, nil, "+",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -25,7 +22,6 @@ import (
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"modernc.org/sqlite"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
@@ -41,76 +37,9 @@ import (
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// templateDBURL names the in-memory database TestMain migrates once
|
||||
// and every test copies; it lasts while TestMain holds it open.
|
||||
const templateDBURL = "file:template?mode=memory&cache=shared"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db.SetBcryptCost(bcrypt.MinCost)
|
||||
|
||||
template, err := sql.Open("sqlite", templateDBURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = db.NewTestDatabaseFromConn(template).
|
||||
RunMigrations(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
_ = template.Close()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// copyTemplateDB creates the in-memory database dbURL as a copy of
|
||||
// the migrated template, so a test does not migrate the schema
|
||||
// again. The copy lasts until the test ends.
|
||||
func copyTemplateDB(t *testing.T, dbURL string) {
|
||||
t.Helper()
|
||||
|
||||
database, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
conn, err := database.Conn(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("connect test db: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
err = conn.Raw(func(driverConn any) error {
|
||||
restorer, ok := driverConn.(interface {
|
||||
NewRestore(srcURI string) (*sqlite.Backup, error)
|
||||
})
|
||||
if !ok {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
|
||||
restore, restoreErr := restorer.NewRestore(templateDBURL)
|
||||
if restoreErr != nil {
|
||||
return fmt.Errorf("start restore: %w", restoreErr)
|
||||
}
|
||||
|
||||
_, restoreErr = restore.Step(-1)
|
||||
if restoreErr != nil {
|
||||
_ = restore.Finish()
|
||||
|
||||
return fmt.Errorf("restore: %w", restoreErr)
|
||||
}
|
||||
|
||||
return restore.Finish()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("copy template db: %v", err)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -155,7 +84,6 @@ func newTestServerWith(
|
||||
"file:test_%p?mode=memory&cache=shared",
|
||||
t,
|
||||
)
|
||||
copyTemplateDB(t, dbURL)
|
||||
|
||||
var srv *server.Server
|
||||
|
||||
@@ -181,8 +109,6 @@ func newTestServerWith(
|
||||
|
||||
cfg.DBURL = dbURL
|
||||
cfg.Port = 0
|
||||
// One line: each line is a database write per new session.
|
||||
cfg.MOTD = "neoirc test server"
|
||||
cfg.HashcashBits = hashcashBits
|
||||
cfg.OperName = operName
|
||||
cfg.OperPassword = operPassword
|
||||
@@ -293,7 +219,6 @@ func newTestHandlers(
|
||||
Config: cfg,
|
||||
Database: database,
|
||||
Broker: brk,
|
||||
Globals: globs,
|
||||
})
|
||||
|
||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// dispatchOperCommand routes the server operator commands
|
||||
// OPER, KILL and WALLOPS to their handlers.
|
||||
func (hdlr *Handlers) dispatchOperCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, command string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdOper:
|
||||
hdlr.handleOper(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdKill:
|
||||
hdlr.handleKill(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdWallops:
|
||||
hdlr.handleWallops(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserhost handles USERHOST: user@host for each of
|
||||
// up to five nicks given in the body.
|
||||
func (hdlr *Handlers) handleUserhost(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdUserhost},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := hdlr.svc.UserhostReply(
|
||||
request.Context(), lines, hdlr.serverName(),
|
||||
)
|
||||
if hdlr.handleServiceError(
|
||||
writer, request, clientID, sessionID, nick, err,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplUserHost, nick, nil, reply,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleVersion handles VERSION.
|
||||
func (hdlr *Handlers) handleVersion(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID, irc.RplVersion, nick,
|
||||
[]string{
|
||||
hdlr.svc.ServerVersion() + ".",
|
||||
hdlr.serverName(),
|
||||
},
|
||||
"",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleAdmin handles ADMIN.
|
||||
func (hdlr *Handlers) handleAdmin(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
location1, location2, email := hdlr.svc.AdminLines(srvName)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminMe, nick,
|
||||
[]string{srvName}, "Administrative info",
|
||||
)
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminLoc1, nick, nil, location1,
|
||||
)
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminLoc2, nick, nil, location2,
|
||||
)
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminEmail, nick, nil, email,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleInfo handles INFO.
|
||||
func (hdlr *Handlers) handleInfo(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
for _, line := range hdlr.svc.InfoLines() {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplInfo, nick, nil, line,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
||||
"End of /INFO list",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleTime handles TIME: the server's local time.
|
||||
func (hdlr *Handlers) handleTime(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID, irc.RplTime, nick,
|
||||
[]string{hdlr.serverName()},
|
||||
time.Now().Format(time.RFC1123),
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleKill handles KILL: the body is the target nick and
|
||||
// an optional reason.
|
||||
func (hdlr *Handlers) handleKill(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
lines := bodyLines()
|
||||
|
||||
targetNick := ""
|
||||
if len(lines) > 0 {
|
||||
targetNick = strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
if targetNick == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdKill},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if len(lines) > 1 {
|
||||
reason = lines[1]
|
||||
}
|
||||
|
||||
err := hdlr.svc.KillUser(
|
||||
request.Context(), sessionID, nick,
|
||||
targetNick, reason,
|
||||
)
|
||||
if hdlr.handleServiceError(
|
||||
writer, request, clientID, sessionID, nick, err,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleWallops handles WALLOPS: the body is the message.
|
||||
func (hdlr *Handlers) handleWallops(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdWallops},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := hdlr.svc.SendWallops(
|
||||
request.Context(), sessionID, nick,
|
||||
strings.Join(lines, " "),
|
||||
)
|
||||
if hdlr.handleServiceError(
|
||||
writer, request, clientID, sessionID, nick, err,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleUserMode handles MODE for a nick: a query when the
|
||||
// body is empty, otherwise a change by the mode string in
|
||||
// the body. Only the caller's own nick, in any letter case,
|
||||
// is allowed.
|
||||
func (hdlr *Handlers) handleUserMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, target string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if !strings.EqualFold(target, nick) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUsersDoNotMatch, nick, nil,
|
||||
"Can't change mode for other users",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx := request.Context()
|
||||
|
||||
var (
|
||||
modes string
|
||||
err error
|
||||
)
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) > 0 {
|
||||
modes, err = hdlr.svc.ApplyUserMode(
|
||||
ctx, sessionID, lines[0],
|
||||
)
|
||||
} else {
|
||||
modes, err = hdlr.svc.QueryUserMode(ctx, sessionID)
|
||||
}
|
||||
|
||||
if hdlr.handleServiceError(
|
||||
writer, request, clientID, sessionID, nick, err,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUmodeIs, nick, nil, modes,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{statusKey: "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
//nolint:paralleltest // global viper, as in api_test.go
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// Each test starts one server and reuses it for all its
|
||||
// checks, and makes as few requests as it can: servers,
|
||||
// sessions and requests are what makes this package's tests
|
||||
// slow.
|
||||
|
||||
// sendAndPoll sends cmd from the session token and returns
|
||||
// the messages after lastID that the session has been sent,
|
||||
// and the ID of the last of them.
|
||||
func sendAndPoll(
|
||||
tserver *testServer,
|
||||
token string,
|
||||
lastID int64,
|
||||
cmd map[string]any,
|
||||
) ([]map[string]any, int64) {
|
||||
tserver.t.Helper()
|
||||
|
||||
tserver.sendCommand(token, cmd)
|
||||
|
||||
return tserver.pollMessages(token, lastID)
|
||||
}
|
||||
|
||||
// newOperSession creates a session for nick, makes it a
|
||||
// server operator, and returns its token and the ID of the
|
||||
// last message it has been sent.
|
||||
func newOperSession(
|
||||
tserver *testServer, nick string,
|
||||
) (string, int64) {
|
||||
tserver.t.Helper()
|
||||
|
||||
token := tserver.createSession(nick)
|
||||
|
||||
_, lastID := sendAndPoll(tserver, token, 0, map[string]any{
|
||||
commandKey: irc.CmdOper,
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
return token, lastID
|
||||
}
|
||||
|
||||
// numericBody returns the first body line of the message in
|
||||
// msgs with the given numeric, and fails the test if there
|
||||
// is none.
|
||||
func numericBody(
|
||||
t *testing.T, msgs []map[string]any, numeric string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
msg := findNumericWithParams(msgs, numeric)
|
||||
if msg == nil {
|
||||
t.Fatalf("expected numeric %s, got %v", numeric, msgs)
|
||||
}
|
||||
|
||||
lines, _ := msg[bodyKey].([]any)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
line, _ := lines[0].(string)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
func TestUserhost(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("alice")
|
||||
tserver.createSession("bob")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
|
||||
commandKey: irc.CmdUserhost,
|
||||
bodyKey: []string{"alice", "bob"},
|
||||
})
|
||||
|
||||
body := numericBody(t, msgs, "302")
|
||||
if !strings.HasPrefix(body, "alice=+alice@") ||
|
||||
!strings.Contains(body, " bob=+bob@") {
|
||||
t.Errorf("expected alice and bob, got %q", body)
|
||||
}
|
||||
|
||||
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
|
||||
commandKey: irc.CmdUserhost,
|
||||
bodyKey: []string{"ghost"},
|
||||
})
|
||||
|
||||
body = numericBody(t, msgs, "302")
|
||||
if body != "" {
|
||||
t.Errorf("expected an empty reply for ghost, got %q", body)
|
||||
}
|
||||
|
||||
msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{
|
||||
commandKey: irc.CmdUserhost,
|
||||
})
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionAdminInfoTime(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("frank")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
|
||||
commandKey: irc.CmdVersion,
|
||||
})
|
||||
|
||||
params := getNumericParams(findNumericWithParams(msgs, "351"))
|
||||
if len(params) == 0 || params[0] != "neoirc-test-test." {
|
||||
t.Errorf("expected RPL_VERSION neoirc-test-test., got %v", msgs)
|
||||
}
|
||||
|
||||
for _, check := range []struct {
|
||||
command string
|
||||
numerics []string
|
||||
}{
|
||||
{irc.CmdAdmin, []string{"256", "257", "258", "259"}},
|
||||
{irc.CmdInfo, []string{"371", "374"}},
|
||||
{irc.CmdTime, []string{"391"}},
|
||||
} {
|
||||
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
|
||||
commandKey: check.command,
|
||||
})
|
||||
|
||||
for _, numeric := range check.numerics {
|
||||
if !findNumeric(msgs, numeric) {
|
||||
t.Errorf(
|
||||
"%s: expected %s, got %v",
|
||||
check.command, numeric, msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKill(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
victimToken := tserver.createSession("victim")
|
||||
observerToken := tserver.createSession("observer")
|
||||
notOperToken := tserver.createSession("notoper")
|
||||
operToken, operLastID := newOperSession(tserver, "killer")
|
||||
|
||||
for _, token := range []string{victimToken, observerToken} {
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd,
|
||||
toKey: "#killtest",
|
||||
})
|
||||
}
|
||||
|
||||
_, notOperLastID := tserver.pollMessages(notOperToken, 0)
|
||||
|
||||
msgs, _ := sendAndPoll(tserver, notOperToken, notOperLastID, map[string]any{
|
||||
commandKey: irc.CmdKill,
|
||||
bodyKey: []string{"victim"},
|
||||
})
|
||||
if !findNumeric(msgs, "481") {
|
||||
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
|
||||
}
|
||||
|
||||
for _, check := range []struct {
|
||||
numeric string
|
||||
body []string
|
||||
}{
|
||||
{"461", []string{}},
|
||||
{"401", []string{"ghost"}},
|
||||
{"483", []string{"killer"}},
|
||||
} {
|
||||
msgs, operLastID = sendAndPoll(tserver, operToken, operLastID, map[string]any{
|
||||
commandKey: irc.CmdKill,
|
||||
bodyKey: check.body,
|
||||
})
|
||||
if !findNumeric(msgs, check.numeric) {
|
||||
t.Errorf(
|
||||
"KILL %v: expected %s, got %v",
|
||||
check.body, check.numeric, msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_, observerLastID := tserver.pollMessages(observerToken, 0)
|
||||
|
||||
status, result := tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: irc.CmdKill,
|
||||
bodyKey: []string{"victim", "go away"},
|
||||
})
|
||||
if status != http.StatusOK || result[statusKey] != "ok" {
|
||||
t.Fatalf("expected 200 and status ok, got %d: %v", status, result)
|
||||
}
|
||||
|
||||
msgs, _ = tserver.pollMessages(observerToken, observerLastID)
|
||||
if !findMessage(msgs, irc.CmdQuit, "victim") {
|
||||
t.Errorf("expected the observer to see QUIT, got %v", msgs)
|
||||
}
|
||||
|
||||
status, _ = tserver.getState(victimToken)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("expected the victim's session gone, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallops(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
receiverToken := tserver.createSession("receiver")
|
||||
plainToken := tserver.createSession("plain")
|
||||
operToken, operLastID := newOperSession(tserver, "walloper")
|
||||
|
||||
_, receiverLastID := sendAndPoll(tserver, receiverToken, 0, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "receiver",
|
||||
bodyKey: []string{"+w"},
|
||||
})
|
||||
_, plainLastID := tserver.pollMessages(plainToken, 0)
|
||||
|
||||
msgs, plainLastID := sendAndPoll(tserver, plainToken, plainLastID, map[string]any{
|
||||
commandKey: irc.CmdWallops,
|
||||
bodyKey: []string{"not allowed"},
|
||||
})
|
||||
if !findNumeric(msgs, "481") {
|
||||
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
|
||||
}
|
||||
|
||||
msgs, _ = sendAndPoll(tserver, operToken, operLastID, map[string]any{
|
||||
commandKey: irc.CmdWallops,
|
||||
})
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
|
||||
}
|
||||
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: irc.CmdWallops,
|
||||
bodyKey: []string{"server going down"},
|
||||
})
|
||||
|
||||
msgs, _ = tserver.pollMessages(receiverToken, receiverLastID)
|
||||
if !findMessage(msgs, irc.CmdWallops, "walloper") {
|
||||
t.Errorf("expected WALLOPS for the +w user, got %v", msgs)
|
||||
}
|
||||
|
||||
msgs, _ = tserver.pollMessages(plainToken, plainLastID)
|
||||
if findMessage(msgs, irc.CmdWallops, "walloper") {
|
||||
t.Errorf("WALLOPS reached a user without +w: %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMode(t *testing.T) {
|
||||
const (
|
||||
nick = "alice"
|
||||
other = "other"
|
||||
)
|
||||
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
token := tserver.createSession(nick)
|
||||
otherToken := tserver.createSession(other)
|
||||
operToken, operLastID := newOperSession(tserver, "deoper")
|
||||
|
||||
// mode sends MODE for target from token, with modeStr
|
||||
// unless it is empty, and returns the session's next
|
||||
// messages.
|
||||
mode := func(
|
||||
token string, lastID int64, target, modeStr string,
|
||||
) ([]map[string]any, int64) {
|
||||
cmd := map[string]any{commandKey: modeCmd, toKey: target}
|
||||
if modeStr != "" {
|
||||
cmd[bodyKey] = []string{modeStr}
|
||||
}
|
||||
|
||||
return sendAndPoll(tserver, token, lastID, cmd)
|
||||
}
|
||||
|
||||
_, otherLastID := mode(otherToken, 0, other, "+w")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
for _, check := range []struct {
|
||||
target, modeStr, numeric, modes string
|
||||
}{
|
||||
{nick, "+w", "221", "+w"},
|
||||
{nick, "", "221", "+w"},
|
||||
{strings.ToUpper(nick), "-w", "221", "+"},
|
||||
{nick, "+z", "501", ""},
|
||||
{nick, "+o", "501", ""},
|
||||
{other, "", "502", ""},
|
||||
{other, "-w", "502", ""},
|
||||
} {
|
||||
var msgs []map[string]any
|
||||
|
||||
msgs, lastID = mode(token, lastID, check.target, check.modeStr)
|
||||
|
||||
if check.numeric != "221" {
|
||||
if !findNumeric(msgs, check.numeric) ||
|
||||
findNumeric(msgs, "221") {
|
||||
t.Errorf(
|
||||
"MODE %s %s: expected only %s, got %v",
|
||||
check.target, check.modeStr,
|
||||
check.numeric, msgs,
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
got := numericBody(t, msgs, "221")
|
||||
if got != check.modes {
|
||||
t.Errorf(
|
||||
"MODE %s %s: expected %q, got %q",
|
||||
check.target, check.modeStr, check.modes, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
msgs, _ := mode(otherToken, otherLastID, other, "")
|
||||
if got := numericBody(t, msgs, "221"); got != "+w" {
|
||||
t.Errorf("%s's modes changed to %q", other, got)
|
||||
}
|
||||
|
||||
msgs, _ = mode(operToken, operLastID, "deoper", "-o")
|
||||
if got := numericBody(t, msgs, "221"); got != "+" {
|
||||
t.Errorf("after -o: expected +, got %q", got)
|
||||
}
|
||||
}
|
||||
+34
-131
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||
// reply on the wire, and logs any other error.
|
||||
// reply on the wire.
|
||||
func (c *Conn) sendIRCError(err error) {
|
||||
var ircErr *service.IRCError
|
||||
if errors.As(err, &ircErr) {
|
||||
@@ -21,11 +21,7 @@ func (c *Conn) sendIRCError(err error) {
|
||||
args = append(args, ircErr.Params...)
|
||||
args = append(args, ircErr.Message)
|
||||
c.sendNumeric(ircErr.Code, args...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Error("command failed", "error", err)
|
||||
}
|
||||
|
||||
// handleCAP silently acknowledges CAP negotiation.
|
||||
@@ -349,10 +345,7 @@ 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
|
||||
@@ -434,7 +427,7 @@ func (c *Conn) handleMode(
|
||||
if strings.HasPrefix(target, "#") {
|
||||
c.handleChannelMode(ctx, msg)
|
||||
} else {
|
||||
c.handleUserMode(ctx, msg)
|
||||
c.handleUserMode(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,14 +686,11 @@ func (c *Conn) applyChannelModes(
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserMode handles MODE for a nick: a query without a
|
||||
// mode string, otherwise a change. Only the client's own
|
||||
// nick, in any letter case, is allowed.
|
||||
func (c *Conn) handleUserMode(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if !strings.EqualFold(msg.Params[0], c.currentNick()) {
|
||||
// handleUserMode handles MODE for users.
|
||||
func (c *Conn) handleUserMode(msg *Message) {
|
||||
target := msg.Params[0]
|
||||
|
||||
if !strings.EqualFold(target, c.nick) {
|
||||
c.sendNumeric(
|
||||
irc.ErrUsersDoNotMatch,
|
||||
"Can't change mode for other users",
|
||||
@@ -709,26 +699,8 @@ func (c *Conn) handleUserMode(
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
modes string
|
||||
err error
|
||||
)
|
||||
|
||||
if len(msg.Params) == 1 {
|
||||
modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
|
||||
} else {
|
||||
modes, err = c.svc.ApplyUserMode(
|
||||
ctx, c.sessionID, msg.Params[1],
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplUmodeIs, modes)
|
||||
// We don't support user modes beyond the basics.
|
||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
||||
}
|
||||
|
||||
// handleNames replies with channel member list.
|
||||
@@ -1276,116 +1248,47 @@ func (c *Conn) handleInvite(
|
||||
)
|
||||
}
|
||||
|
||||
// handleUserhost replies with user@host for each of up to
|
||||
// five nicks.
|
||||
// handleUserhost replies with USERHOST info.
|
||||
func (c *Conn) handleUserhost(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
irc.CmdUserhost, "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := c.svc.UserhostReply(
|
||||
ctx, msg.Params, c.serverSfx,
|
||||
)
|
||||
replies := make([]string, 0, len(msg.Params))
|
||||
|
||||
for _, nick := range msg.Params {
|
||||
sid, err := c.database.GetSessionByNick(ctx, nick)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplUserHost, reply)
|
||||
hostInfo, _ := c.database.GetSessionHostInfo(
|
||||
ctx, sid,
|
||||
)
|
||||
|
||||
host := "*"
|
||||
if hostInfo != nil {
|
||||
host = hostInfo.Hostname
|
||||
}
|
||||
|
||||
// handleVersion replies with the server version.
|
||||
func (c *Conn) handleVersion() {
|
||||
c.sendNumeric(
|
||||
irc.RplVersion,
|
||||
c.svc.ServerVersion()+".", c.serverSfx, "",
|
||||
isOper, _ := c.database.IsSessionOper(ctx, sid)
|
||||
|
||||
operStar := ""
|
||||
if isOper {
|
||||
operStar = "*"
|
||||
}
|
||||
|
||||
replies = append(
|
||||
replies,
|
||||
nick+operStar+"=+"+nick+"@"+host,
|
||||
)
|
||||
}
|
||||
|
||||
// handleAdmin replies with the server's admin info.
|
||||
func (c *Conn) handleAdmin() {
|
||||
location1, location2, email := c.svc.AdminLines(c.serverSfx)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminMe, c.serverSfx, "Administrative info",
|
||||
)
|
||||
c.sendNumeric(irc.RplAdminLoc1, location1)
|
||||
c.sendNumeric(irc.RplAdminLoc2, location2)
|
||||
c.sendNumeric(irc.RplAdminEmail, email)
|
||||
}
|
||||
|
||||
// handleInfo replies with the server's software info.
|
||||
func (c *Conn) handleInfo() {
|
||||
for _, line := range c.svc.InfoLines() {
|
||||
c.sendNumeric(irc.RplInfo, line)
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplEndOfInfo, "End of /INFO list")
|
||||
}
|
||||
|
||||
// handleTime replies with the server's local time.
|
||||
func (c *Conn) handleTime() {
|
||||
c.sendNumeric(
|
||||
irc.RplTime,
|
||||
c.serverSfx, time.Now().Format(time.RFC1123),
|
||||
irc.RplUserHost,
|
||||
strings.Join(replies, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// handleKill handles KILL <nick> [<reason>].
|
||||
func (c *Conn) handleKill(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
irc.CmdKill, "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if len(msg.Params) > 1 {
|
||||
reason = msg.Params[1]
|
||||
}
|
||||
|
||||
err := c.svc.KillUser(
|
||||
ctx, c.sessionID, c.currentNick(),
|
||||
msg.Params[0], reason,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWallops handles WALLOPS <message>.
|
||||
func (c *Conn) handleWallops(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
irc.CmdWallops, "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := c.svc.SendWallops(
|
||||
ctx, c.sessionID, c.currentNick(), msg.Params[0],
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-120
@@ -22,7 +22,6 @@ 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
|
||||
@@ -47,11 +46,6 @@ type Conn struct {
|
||||
serverSfx string
|
||||
commands map[string]cmdHandler
|
||||
|
||||
// writeMu serializes writes to conn, which come from
|
||||
// serve(), from the relay goroutine and, for KILL, from
|
||||
// Disconnect.
|
||||
writeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
username string
|
||||
@@ -68,7 +62,6 @@ type Conn struct {
|
||||
|
||||
lastQueueID int64
|
||||
closed bool
|
||||
killed bool
|
||||
}
|
||||
|
||||
func newConn(
|
||||
@@ -104,57 +97,6 @@ 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 func() { _ = c.conn.Close() }()
|
||||
|
||||
c.sendWithin(
|
||||
killWriteWindow,
|
||||
FormatMessage(
|
||||
c.serverSfx, irc.CmdKill, nick, reason,
|
||||
),
|
||||
)
|
||||
c.sendWithin(
|
||||
killWriteWindow,
|
||||
"ERROR :Closing Link: "+host+
|
||||
" ("+reason+")",
|
||||
)
|
||||
}
|
||||
|
||||
// buildCommandMap returns a map from IRC command strings
|
||||
// to handler functions.
|
||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
@@ -187,13 +129,7 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
"CAP": func(_ context.Context, msg *Message) {
|
||||
c.handleCAP(msg)
|
||||
},
|
||||
irc.CmdUserhost: c.handleUserhost,
|
||||
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
|
||||
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
|
||||
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
|
||||
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
|
||||
irc.CmdKill: c.handleKill,
|
||||
irc.CmdWallops: c.handleWallops,
|
||||
"USERHOST": c.handleUserhost,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +180,7 @@ func (c *Conn) serve(ctx context.Context) {
|
||||
|
||||
c.handleMessage(ctx, msg)
|
||||
|
||||
if c.isClosed() {
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -253,60 +189,24 @@ 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)
|
||||
|
||||
// KILL has already sent the QUIT and deleted the
|
||||
// session.
|
||||
if !wasKilled {
|
||||
c.svc.BroadcastQuit(
|
||||
ctx, sessID, nick, "Connection closed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.conn.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
// isClosed reports whether QUIT or KILL has ended the
|
||||
// connection.
|
||||
func (c *Conn) isClosed() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.closed
|
||||
}
|
||||
|
||||
// currentNick returns c.nick, read under c.mu.
|
||||
func (c *Conn) currentNick() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.nick
|
||||
}
|
||||
|
||||
// send writes a formatted IRC line to the connection.
|
||||
func (c *Conn) send(line string) {
|
||||
c.sendWithin(writeTimeout, line)
|
||||
}
|
||||
|
||||
// sendWithin writes a formatted IRC line to the connection,
|
||||
// giving up after timeout.
|
||||
func (c *Conn) sendWithin(
|
||||
timeout time.Duration,
|
||||
line string,
|
||||
) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
_ = c.conn.SetWriteDeadline(
|
||||
time.Now().Add(timeout),
|
||||
time.Now().Add(writeTimeout),
|
||||
)
|
||||
|
||||
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||
@@ -368,17 +268,6 @@ func (c *Conn) handleMessage(
|
||||
return
|
||||
}
|
||||
|
||||
// Every command is activity, so the idle cleanup must not
|
||||
// remove this user.
|
||||
err := c.database.UpdateLastSeen(
|
||||
ctx, c.sessionID, c.clientID,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to update last_seen", "error", err,
|
||||
)
|
||||
}
|
||||
|
||||
handler, ok := c.commands[msg.Command]
|
||||
if !ok {
|
||||
c.sendNumeric(
|
||||
@@ -498,10 +387,7 @@ 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
|
||||
}
|
||||
@@ -512,9 +398,6 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
||||
c.registered = true
|
||||
c.mu.Unlock()
|
||||
|
||||
// So that KILL, from either transport, can close it.
|
||||
c.svc.RegisterWireConn(sessionID, c)
|
||||
|
||||
// If PASS was provided before registration, set the
|
||||
// session password.
|
||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package ircserver_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||
)
|
||||
|
||||
// newKillVictim returns a Conn for the nick victim whose
|
||||
// socket is one end of a net.Pipe, and the other end, from
|
||||
// which the test reads what the victim is sent. A write to
|
||||
// the pipe blocks until the other end reads.
|
||||
func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
serverSide, clientSide := net.Pipe()
|
||||
|
||||
t.Cleanup(func() { _ = clientSide.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError},
|
||||
))
|
||||
cfg := &config.Config{ServerName: testServerName}
|
||||
|
||||
return ircserver.NewTestConn(
|
||||
log, cfg, serverSide, "victim",
|
||||
), clientSide
|
||||
}
|
||||
|
||||
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
|
||||
// that Disconnect returns although the victim never reads,
|
||||
// so a KILL cannot stall the operator who sent it. The
|
||||
// limit is well below one KillWriteWindow, so a Disconnect
|
||||
// that waits on any write to the victim fails.
|
||||
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
victim, _ := newKillVictim(t)
|
||||
returned := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
victim.Disconnect("killed by oper")
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(ircserver.KillWriteWindow / 4):
|
||||
t.Fatal("Disconnect waited for the victim to read")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectNotifiesAndClosesVictim checks that a
|
||||
// victim that reads is sent KILL and ERROR, and that its
|
||||
// connection is then closed.
|
||||
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
victim, clientSide := newKillVictim(t)
|
||||
received := make(chan []string, 1)
|
||||
|
||||
go func() {
|
||||
var lines []string
|
||||
|
||||
scanner := bufio.NewScanner(clientSide)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
received <- lines
|
||||
}()
|
||||
|
||||
victim.Disconnect("killed by oper")
|
||||
|
||||
var lines []string
|
||||
|
||||
select {
|
||||
case lines = <-received:
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("the victim's connection was not closed")
|
||||
}
|
||||
|
||||
joined := strings.Join(lines, "\n")
|
||||
|
||||
if !strings.Contains(joined, "KILL victim :killed by oper") {
|
||||
t.Errorf("missing KILL line, got: %q", joined)
|
||||
}
|
||||
|
||||
if !strings.Contains(joined, "ERROR :Closing Link:") {
|
||||
t.Errorf("missing ERROR line, got: %q", joined)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,10 @@ 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"
|
||||
)
|
||||
|
||||
@@ -21,14 +19,8 @@ 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, globs, log,
|
||||
database, brk, cfg, log,
|
||||
)
|
||||
|
||||
return &Server{ //nolint:exhaustruct
|
||||
@@ -55,24 +47,3 @@ func (s *Server) Stop() {
|
||||
func (s *Server) Listener() net.Listener {
|
||||
return s.listener
|
||||
}
|
||||
|
||||
// KillWriteWindow is how long Disconnect allows each write
|
||||
// to a killed victim.
|
||||
const KillWriteWindow = killWriteWindow
|
||||
|
||||
// NewTestConn returns a Conn for nick on tcpConn, without a
|
||||
// server, database or service behind it.
|
||||
func NewTestConn(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
tcpConn net.Conn,
|
||||
nick string,
|
||||
) *Conn {
|
||||
conn := newConn(
|
||||
context.Background(), tcpConn, log,
|
||||
nil, nil, cfg, nil,
|
||||
)
|
||||
conn.nick = nick
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
)
|
||||
|
||||
// TestIntegrationTwoClients is a comprehensive integration
|
||||
@@ -764,371 +762,6 @@ func TestIntegrationTwoClients(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tier 3 Utility Command Integration Tests ──────────
|
||||
|
||||
// TestIntegrationUserhost verifies the USERHOST command
|
||||
// returns user@host info for connected nicks.
|
||||
func TestIntegrationUserhost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
bob.send("AWAY :lunch")
|
||||
bob.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 306 ")
|
||||
})
|
||||
|
||||
for _, step := range []struct {
|
||||
line, want string
|
||||
}{
|
||||
{"USERHOST alice", " 302 alice alice=+alice@"},
|
||||
{"USERHOST alice bob", " :alice=+alice@"},
|
||||
{"USERHOST alice bob", " bob=-bob@"},
|
||||
{"USERHOST nobody", " 302 alice :"},
|
||||
{"USERHOST", " 461 alice USERHOST :"},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 302 ") ||
|
||||
strings.Contains(l, " 461 ")
|
||||
})
|
||||
assertContains(t, reply, step.want, step.line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationVersion verifies the VERSION command
|
||||
// returns the server version string.
|
||||
func TestIntegrationVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
alice.send("VERSION")
|
||||
|
||||
aliceReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 351 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceReply, " 351 ",
|
||||
"RPL_VERSION",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, "neoirc",
|
||||
"VERSION reply contains server name",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationAdmin verifies the ADMIN command returns
|
||||
// server admin info (256–259 numerics).
|
||||
func TestIntegrationAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
alice.send("ADMIN")
|
||||
|
||||
aliceReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 259 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceReply, " 256 ",
|
||||
"RPL_ADMINME",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, " 257 ",
|
||||
"RPL_ADMINLOC1",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, " 258 ",
|
||||
"RPL_ADMINLOC2",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, " 259 ",
|
||||
"RPL_ADMINEMAIL",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationInfo verifies the INFO command returns
|
||||
// server information (371/374 numerics).
|
||||
func TestIntegrationInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
alice.send("INFO")
|
||||
|
||||
aliceReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 374 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceReply, " 371 ",
|
||||
"RPL_INFO",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, " 374 ",
|
||||
"RPL_ENDOFINFO",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, "neoirc",
|
||||
"INFO reply mentions server name",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationTime verifies the TIME command returns
|
||||
// the server time (391 numeric).
|
||||
func TestIntegrationTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
alice.send("TIME")
|
||||
|
||||
aliceReply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 391 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceReply, " 391 ",
|
||||
"RPL_TIME",
|
||||
)
|
||||
assertContains(
|
||||
t, aliceReply, testServerName,
|
||||
"TIME reply includes server name",
|
||||
)
|
||||
}
|
||||
|
||||
// becomeOper sends OPER with newTestEnvWithOper's
|
||||
// credentials and waits for RPL_YOUREOPER.
|
||||
func (tc *testClient) becomeOper() {
|
||||
tc.t.Helper()
|
||||
|
||||
tc.send("OPER testoper testpass")
|
||||
tc.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 381 ")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIntegrationKillRefused covers the KILL errors: not an
|
||||
// operator, no such nick, and killing yourself.
|
||||
func TestIntegrationKillRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithOper(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
for _, step := range []struct {
|
||||
line, numeric string
|
||||
}{
|
||||
{"KILL bob :nope", " 481 "},
|
||||
{"OPER testoper testpass", " 381 "},
|
||||
{"KILL nobody123 :gone", " 401 "},
|
||||
{"KILL alice :me", " 483 "},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, step.numeric)
|
||||
})
|
||||
assertContains(t, reply, step.numeric, step.line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationKill checks that the victim of a KILL is
|
||||
// told why, is disconnected, and is gone from its channels.
|
||||
func TestIntegrationKill(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithOper(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
alice.joinAndDrain("#killtest")
|
||||
bob.joinAndDrain("#killtest")
|
||||
|
||||
// Drain alice's view of bob's join.
|
||||
alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, "JOIN") &&
|
||||
strings.Contains(l, "bob")
|
||||
})
|
||||
|
||||
alice.becomeOper()
|
||||
alice.send("KILL bob :bad behavior")
|
||||
|
||||
bobLines := bob.readUntilClosed()
|
||||
assertContains(
|
||||
t, bobLines, "KILL",
|
||||
"victim receives KILL before disconnect",
|
||||
)
|
||||
assertContains(
|
||||
t, bobLines, "ERROR :Closing Link",
|
||||
"victim receives ERROR before disconnect",
|
||||
)
|
||||
assertContains(
|
||||
t, bobLines, "bad behavior",
|
||||
"KILL reason delivered to victim",
|
||||
)
|
||||
|
||||
// alice should see bob's QUIT relay.
|
||||
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, "QUIT") &&
|
||||
strings.Contains(l, "bob")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceSeesQuit, "Killed",
|
||||
"KILL reason in QUIT message",
|
||||
)
|
||||
|
||||
// bob must be gone from the channel member list.
|
||||
alice.send("NAMES #killtest")
|
||||
|
||||
aliceNames := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 366 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceNames, "alice",
|
||||
"alice still in NAMES after killing bob",
|
||||
)
|
||||
assertNotContains(
|
||||
t, aliceNames, "bob",
|
||||
"killed user must not appear in NAMES",
|
||||
)
|
||||
|
||||
// ...nor from WHO.
|
||||
alice.send("WHO #killtest")
|
||||
|
||||
aliceWho := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 315 ")
|
||||
})
|
||||
assertNotContains(
|
||||
t, aliceWho, "bob",
|
||||
"killed user must not appear in WHO",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationWallops verifies the WALLOPS command:
|
||||
// oper can broadcast to +w users.
|
||||
func TestIntegrationWallops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithOper(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
// Non-oper WALLOPS should fail.
|
||||
alice.send("WALLOPS :test broadcast")
|
||||
|
||||
aliceWallopsFail := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 481 ")
|
||||
})
|
||||
assertContains(
|
||||
t, aliceWallopsFail, " 481 ",
|
||||
"ERR_NOPRIVILEGES for non-oper WALLOPS",
|
||||
)
|
||||
|
||||
alice.becomeOper()
|
||||
|
||||
// bob sets +w to receive wallops.
|
||||
bob.send("MODE bob +w")
|
||||
bob.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 221 ")
|
||||
})
|
||||
|
||||
// alice sends WALLOPS.
|
||||
alice.send("WALLOPS :important announcement")
|
||||
|
||||
// bob (who has +w) should receive it.
|
||||
bobWallops := bob.readUntil(func(l string) bool {
|
||||
return strings.Contains(
|
||||
l, "important announcement",
|
||||
)
|
||||
})
|
||||
assertContains(
|
||||
t, bobWallops, "important announcement",
|
||||
"bob receives WALLOPS message",
|
||||
)
|
||||
assertContains(
|
||||
t, bobWallops, "WALLOPS",
|
||||
"message is WALLOPS command",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationUserMode covers MODE for a nick: changes
|
||||
// and queries of your own modes in any letter case, refusal
|
||||
// for another nick, and a rejected mode string changing
|
||||
// nothing.
|
||||
func TestIntegrationUserMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
const (
|
||||
notYours = " 502 alice :Can't change mode for other users"
|
||||
unknown = " 501 alice :Unknown MODE flag"
|
||||
)
|
||||
|
||||
for _, step := range []struct {
|
||||
line, want string
|
||||
}{
|
||||
{"MODE ALICE +w", " 221 alice +w"},
|
||||
{"MODE bob", notYours},
|
||||
{"MODE bob -w", notYours},
|
||||
{"MODE alice xw", unknown},
|
||||
{"MODE alice -w+z", unknown},
|
||||
{"MODE alice", " 221 alice +w"},
|
||||
{"MODE alice +w-w", " 221 alice +"},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 221 ") ||
|
||||
strings.Contains(l, " 501 ") ||
|
||||
strings.Contains(l, " 502 ")
|
||||
})
|
||||
|
||||
last := reply[len(reply)-1]
|
||||
if !strings.HasSuffix(last, step.want) {
|
||||
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationModeSecret tests +s (secret) channel
|
||||
// mode — verifies that +s can be set and the mode is
|
||||
// reflected in MODE queries.
|
||||
@@ -1282,30 +915,3 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
|
||||
"carol receives trio message",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationDefaultServerNameFallback checks that with
|
||||
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and
|
||||
// TIME name the server "neoirc".
|
||||
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithConfig(t, &config.Config{})
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
for _, step := range []struct {
|
||||
line, lastNumeric, want string
|
||||
}{
|
||||
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
|
||||
{"ADMIN", " 259 ", " 256 alice neoirc "},
|
||||
{"TIME", " 391 ", " 391 alice neoirc "},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, step.lastNumeric)
|
||||
})
|
||||
assertContains(t, reply, step.want, step.line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +120,6 @@ 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:
|
||||
@@ -339,18 +337,6 @@ 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,
|
||||
|
||||
@@ -2,7 +2,6 @@ package ircserver_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -37,44 +36,12 @@ type testEnv struct {
|
||||
srv *ircserver.Server
|
||||
}
|
||||
|
||||
// testServerName is the SERVER_NAME of newTestEnv.
|
||||
const testServerName = "test.irc"
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
return newTestEnvWithConfig(t, &config.Config{
|
||||
ServerName: testServerName,
|
||||
MOTD: "Welcome to test IRC",
|
||||
})
|
||||
}
|
||||
|
||||
// newTestEnvWithOper is newTestEnv with the operator name
|
||||
// testoper and password testpass.
|
||||
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
return newTestEnvWithConfig(t, &config.Config{
|
||||
ServerName: testServerName,
|
||||
MOTD: "Welcome to test IRC",
|
||||
OperName: "testoper",
|
||||
OperPassword: "testpass",
|
||||
})
|
||||
}
|
||||
|
||||
// newTestEnvWithConfig starts an IRC server with cfg on a
|
||||
// fresh database.
|
||||
func newTestEnvWithConfig(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
// A random name, so that no other test environment, not
|
||||
// even an earlier run of the same test, can share it.
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||
rand.Text(),
|
||||
t.Name(),
|
||||
)
|
||||
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
@@ -82,13 +49,6 @@ func newTestEnvWithConfig(
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
t.Logf("close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
_, err = conn.ExecContext(
|
||||
@@ -107,6 +67,11 @@ func newTestEnvWithConfig(
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
MOTD: "Welcome to test IRC",
|
||||
}
|
||||
|
||||
var listenConfig net.ListenConfig
|
||||
|
||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||
@@ -133,9 +98,14 @@ func newTestEnvWithConfig(
|
||||
t.Fatalf("start irc server: %v", err)
|
||||
}
|
||||
|
||||
// Cleanups run last registered first, so the server stops
|
||||
// before its database is closed.
|
||||
t.Cleanup(srv.Stop)
|
||||
t.Cleanup(func() {
|
||||
srv.Stop()
|
||||
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
t.Logf("close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &testEnv{
|
||||
database: database,
|
||||
@@ -245,33 +215,6 @@ func (tc *testClient) register(nick string) []string {
|
||||
})
|
||||
}
|
||||
|
||||
// readUntilClosed returns the lines received until the
|
||||
// server closes the connection, and fails the test if it is
|
||||
// still open after testTimeout.
|
||||
func (tc *testClient) readUntilClosed() []string {
|
||||
tc.t.Helper()
|
||||
|
||||
_ = tc.conn.SetReadDeadline(
|
||||
time.Now().Add(testTimeout),
|
||||
)
|
||||
|
||||
var lines []string
|
||||
|
||||
for tc.scanner.Scan() {
|
||||
lines = append(lines, tc.scanner.Text())
|
||||
}
|
||||
|
||||
err := tc.scanner.Err()
|
||||
if err != nil {
|
||||
tc.t.Fatalf(
|
||||
"connection not closed: %v (lines: %v)",
|
||||
err, lines,
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// assertContains checks that at least one line matches the
|
||||
// given substring.
|
||||
func assertContains(
|
||||
@@ -290,27 +233,6 @@ 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) {
|
||||
@@ -347,20 +269,6 @@ func TestRegistration(t *testing.T) {
|
||||
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
||||
}
|
||||
|
||||
// TestEachTestEnvHasItsOwnDatabase checks that a test
|
||||
// environment starts on an empty database while another one
|
||||
// is still open.
|
||||
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := newTestEnv(t)
|
||||
first.dial(t).register("samenick")
|
||||
|
||||
second := newTestEnv(t)
|
||||
lines := second.dial(t).register("samenick")
|
||||
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
||||
}
|
||||
|
||||
func TestWelcomeContainsNick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -388,39 +296,6 @@ func TestPingPong(t *testing.T) {
|
||||
assertContains(t, lines, "PONG", "PONG response")
|
||||
}
|
||||
|
||||
// TestIdleCleanupKeepsActiveUser checks that a user who
|
||||
// sends commands is kept by the idle cleanup, and a user who
|
||||
// sends nothing is not.
|
||||
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const idleTimeout = 500 * time.Millisecond
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
active := env.dial(t)
|
||||
active.register("active")
|
||||
|
||||
idle := env.dial(t)
|
||||
idle.register("idle")
|
||||
|
||||
time.Sleep(idleTimeout)
|
||||
active.sendAndExpect("PING :still here", "PONG")
|
||||
|
||||
// The idle cleanup in internal/handlers removes exactly
|
||||
// the users this returns.
|
||||
stale, err := env.database.GetStaleOrphanSessions(
|
||||
t.Context(), time.Now().Add(-idleTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get stale sessions: %v", err)
|
||||
}
|
||||
|
||||
if len(stale) != 1 || stale[0].Nick != "idle" {
|
||||
t.Errorf("cleanup removes %v, want only idle", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+2
-363
@@ -5,21 +5,16 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
@@ -27,14 +22,9 @@ import (
|
||||
// Error texts that several commands reply with.
|
||||
const (
|
||||
msgNoSuchChannel = "No such channel"
|
||||
msgNoSuchNick = "No such nick/channel"
|
||||
msgNotChannelOp = "You're not channel operator"
|
||||
)
|
||||
|
||||
// maxUserhostNicks is how many nicks one USERHOST answers
|
||||
// for (RFC 2812).
|
||||
const maxUserhostNicks = 5
|
||||
|
||||
// Params defines the dependencies for creating a Service.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -43,15 +33,6 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Broker *broker.Broker
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// WireConn is a registered IRC connection, which KILL closes.
|
||||
// HTTP clients hold no connection and register none.
|
||||
type WireConn interface {
|
||||
// Disconnect tells the client why and closes the
|
||||
// connection.
|
||||
Disconnect(reason string)
|
||||
}
|
||||
|
||||
// Service provides shared business logic for IRC commands.
|
||||
@@ -59,11 +40,7 @@ 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.
|
||||
@@ -72,9 +49,7 @@ func New(params Params) *Service {
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
globals: params.Globals,
|
||||
log: params.Logger.Get(),
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,219 +59,16 @@ func NewTestService(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
cfg *config.Config,
|
||||
globs *globals.Globals,
|
||||
log *slog.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
globals: globs,
|
||||
log: log,
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
// ServerVersion returns the version that VERSION and INFO
|
||||
// report on both transports, such as "neoirc-1.2.3".
|
||||
func (s *Service) ServerVersion() string {
|
||||
version := s.globals.Version
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
return s.globals.Appname + "-" + version
|
||||
}
|
||||
|
||||
// InfoLines returns the lines that INFO sends on both
|
||||
// transports.
|
||||
func (s *Service) InfoLines() []string {
|
||||
return []string{
|
||||
"neoirc — IRC semantics over HTTP",
|
||||
"Version: " + s.ServerVersion(),
|
||||
"Written in Go",
|
||||
"Started: " + s.globals.StartTime.Format(time.RFC1123),
|
||||
}
|
||||
}
|
||||
|
||||
// AdminLines returns the texts of the RPL_ADMINLOC1,
|
||||
// RPL_ADMINLOC2 and RPL_ADMINEMAIL replies, in that order,
|
||||
// that ADMIN sends on both transports.
|
||||
func (s *Service) AdminLines(
|
||||
serverName string,
|
||||
) (string, string, string) {
|
||||
return "neoirc server", "IRC over HTTP", "admin@" + serverName
|
||||
}
|
||||
|
||||
// UserhostReply returns the RPL_USERHOST text for the first
|
||||
// five of the given nicks: "nick=+user@host" entries joined
|
||||
// by spaces, with * after the nick of an operator and - in
|
||||
// place of + for a user who is away. Nicks with no session
|
||||
// are left out. An empty hostname is reported as
|
||||
// serverName.
|
||||
func (s *Service) UserhostReply(
|
||||
ctx context.Context,
|
||||
nicks []string,
|
||||
serverName string,
|
||||
) (string, error) {
|
||||
if len(nicks) > maxUserhostNicks {
|
||||
nicks = nicks[:maxUserhostNicks]
|
||||
}
|
||||
|
||||
infos, err := s.db.GetUserhostInfo(ctx, nicks)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("userhost: %w", err)
|
||||
}
|
||||
|
||||
replies := make([]string, 0, len(infos))
|
||||
|
||||
for _, info := range infos {
|
||||
operStar := ""
|
||||
if info.IsOper {
|
||||
operStar = "*"
|
||||
}
|
||||
|
||||
away := "+"
|
||||
if info.AwayMessage != "" {
|
||||
away = "-"
|
||||
}
|
||||
|
||||
username := info.Username
|
||||
if username == "" {
|
||||
username = info.Nick
|
||||
}
|
||||
|
||||
hostname := info.Hostname
|
||||
if hostname == "" {
|
||||
hostname = serverName
|
||||
}
|
||||
|
||||
replies = append(replies,
|
||||
info.Nick+operStar+"="+away+username+"@"+hostname,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Join(replies, " "), nil
|
||||
}
|
||||
|
||||
// RegisterWireConn records the IRC connection of a session,
|
||||
// so that KillUser can close it.
|
||||
func (s *Service) RegisterWireConn(
|
||||
sessionID int64,
|
||||
conn WireConn,
|
||||
) {
|
||||
s.wireMu.Lock()
|
||||
defer s.wireMu.Unlock()
|
||||
|
||||
s.wireConns[sessionID] = conn
|
||||
}
|
||||
|
||||
// UnregisterWireConn removes what RegisterWireConn recorded,
|
||||
// unless the session has since registered another
|
||||
// connection.
|
||||
func (s *Service) UnregisterWireConn(
|
||||
sessionID int64,
|
||||
conn WireConn,
|
||||
) {
|
||||
s.wireMu.Lock()
|
||||
defer s.wireMu.Unlock()
|
||||
|
||||
if s.wireConns[sessionID] == conn {
|
||||
delete(s.wireConns, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// KillUser carries out an operator's KILL: the target's
|
||||
// channel peers see it quit, its session is deleted, and
|
||||
// its IRC connection, if it has one, is closed. An empty
|
||||
// reason is given as "KILLed".
|
||||
func (s *Service) KillUser(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, targetNick, reason string,
|
||||
) error {
|
||||
err := s.requireOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{targetNick},
|
||||
msgNoSuchNick,
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("kill: %w", err)
|
||||
}
|
||||
|
||||
if targetSID == sessionID {
|
||||
return &IRCError{
|
||||
irc.ErrCantKillServer,
|
||||
nil,
|
||||
"You cannot KILL yourself",
|
||||
}
|
||||
}
|
||||
|
||||
if reason == "" {
|
||||
reason = "KILLed"
|
||||
}
|
||||
|
||||
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||
|
||||
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
|
||||
|
||||
s.wireMu.Lock()
|
||||
conn := s.wireConns[targetSID]
|
||||
s.wireMu.Unlock()
|
||||
|
||||
if conn != nil {
|
||||
conn.Disconnect(quitReason)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendWallops carries out an operator's WALLOPS: the
|
||||
// message goes to every user with user mode +w.
|
||||
func (s *Service) SendWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, message string,
|
||||
) error {
|
||||
err := s.requireOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recipients, err := s.db.GetWallopsSessionIDs(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallops: %w", err)
|
||||
}
|
||||
|
||||
if len(recipients) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal([]string{message})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallops: %w", err)
|
||||
}
|
||||
|
||||
_, _, err = s.FanOut(
|
||||
ctx, irc.CmdWallops, nick, "*",
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallops: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IRCError represents an IRC protocol-level error with a
|
||||
// numeric code that both transports can map to responses.
|
||||
type IRCError struct {
|
||||
@@ -665,7 +437,7 @@ func (s *Service) KickUser(
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{targetNick},
|
||||
msgNoSuchNick,
|
||||
"No such nick/channel",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,7 +649,7 @@ func (s *Service) ApplyMemberMode(
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{targetNick},
|
||||
msgNoSuchNick,
|
||||
"No such nick/channel",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,139 +792,6 @@ func (s *Service) QueryChannelMode(
|
||||
return modes + modeParams
|
||||
}
|
||||
|
||||
// QueryUserMode returns the session's user mode string,
|
||||
// such as "+", "+w" or "+ow".
|
||||
func (s *Service) QueryUserMode(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (string, error) {
|
||||
modes := "+"
|
||||
|
||||
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"query oper flag: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if isOper {
|
||||
modes += "o"
|
||||
}
|
||||
|
||||
isWallops, err := s.db.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"query wallops flag: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if isWallops {
|
||||
modes += "w"
|
||||
}
|
||||
|
||||
return modes, nil
|
||||
}
|
||||
|
||||
// ApplyUserMode applies a user mode string to the session
|
||||
// and returns the resulting mode string. The whole string
|
||||
// is parsed before anything is written, and the flags are
|
||||
// written in one transaction, so a change that is rejected
|
||||
// or fails leaves the modes as they were.
|
||||
func (s *Service) ApplyUserMode(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
modeStr string,
|
||||
) (string, error) {
|
||||
wallops, oper, err := parseUserModeString(modeStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = s.db.SetSessionUserModes(
|
||||
ctx, sessionID, wallops, oper,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apply user modes: %w", err)
|
||||
}
|
||||
|
||||
return s.QueryUserMode(ctx, sessionID)
|
||||
}
|
||||
|
||||
// parseUserModeString parses a user mode string such as
|
||||
// "+w", "-o" or "+w-o" into the new value of each flag it
|
||||
// names, or nil for a flag it does not name; a later letter
|
||||
// overrides an earlier one. The string must start with + or
|
||||
// - and name at least one mode. w may be set or unset, o
|
||||
// only unset (OPER sets it). Anything else is rejected with
|
||||
// ERR_UMODEUNKNOWNFLAG.
|
||||
func parseUserModeString(
|
||||
modeStr string,
|
||||
) (*bool, *bool, error) {
|
||||
unknownFlag := &IRCError{
|
||||
irc.ErrUmodeUnknownFlag, nil, "Unknown MODE flag",
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(modeStr, "+") &&
|
||||
!strings.HasPrefix(modeStr, "-") {
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
|
||||
var wallops, oper *bool
|
||||
|
||||
adding := true
|
||||
|
||||
for _, modeChar := range modeStr {
|
||||
switch modeChar {
|
||||
case '+':
|
||||
adding = true
|
||||
case '-':
|
||||
adding = false
|
||||
case 'w':
|
||||
value := adding
|
||||
wallops = &value
|
||||
case 'o':
|
||||
if adding {
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
|
||||
value := false
|
||||
oper = &value
|
||||
default:
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
}
|
||||
|
||||
if wallops == nil && oper == nil {
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
|
||||
return wallops, oper, nil
|
||||
}
|
||||
|
||||
// requireOper returns ERR_NOPRIVILEGES unless the session
|
||||
// is a server operator.
|
||||
func (s *Service) requireOper(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) error {
|
||||
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check oper: %w", err)
|
||||
}
|
||||
|
||||
if !isOper {
|
||||
return &IRCError{
|
||||
irc.ErrNoPrivileges,
|
||||
nil,
|
||||
"Permission Denied- You're not an IRC operator",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
// change.
|
||||
func (s *Service) broadcastNickChange(
|
||||
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
@@ -58,10 +55,9 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
func() *globals.Globals {
|
||||
return &globals.Globals{
|
||||
return &globals.Globals{ //nolint:exhaustruct
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
},
|
||||
logger.New,
|
||||
@@ -367,249 +363,3 @@ 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")
|
||||
|
||||
modes, err := env.svc.QueryUserMode(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("query user mode: %v", err)
|
||||
}
|
||||
|
||||
if modes != "+" {
|
||||
t.Errorf("expected +, got %s", modes)
|
||||
}
|
||||
|
||||
setUserModes(ctx, t, env.db, sid, userModes{wallops: true})
|
||||
|
||||
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("query user mode: %v", err)
|
||||
}
|
||||
|
||||
if modes != "+w" {
|
||||
t.Errorf("expected +w, got %s", modes)
|
||||
}
|
||||
|
||||
setUserModes(
|
||||
ctx, t, env.db, sid, userModes{oper: true, wallops: true},
|
||||
)
|
||||
|
||||
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("query user mode: %v", err)
|
||||
}
|
||||
|
||||
if modes != "+ow" {
|
||||
t.Errorf("expected +ow, got %s", modes)
|
||||
}
|
||||
|
||||
// A flag that cannot be read is an error, not a flag
|
||||
// that is unset.
|
||||
cancelled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
_, err = env.svc.QueryUserMode(cancelled, sid)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected the read's error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserhostReply(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
createSession(ctx, t, env.db, "alice")
|
||||
bobID := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
// No username or hostname, and an operator.
|
||||
operID, _, _, err := env.db.CreateSession(
|
||||
ctx, "oper", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
setUserModes(ctx, t, env.db, operID, userModes{oper: true})
|
||||
|
||||
_, err = env.svc.SetAway(ctx, bobID, "gone fishing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply, err := env.svc.UserhostReply(ctx, []string{
|
||||
"alice", "bob", "nobody", "oper",
|
||||
}, "srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := "alice=+alice@localhost bob=-bob@localhost " +
|
||||
"oper*=+oper@srv"
|
||||
if reply != want {
|
||||
t.Errorf("want %q, got %q", want, reply)
|
||||
}
|
||||
|
||||
reply, err = env.svc.UserhostReply(
|
||||
ctx, slices.Repeat([]string{"bob"}, 6), "srv",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if strings.Count(reply, "bob=") != 5 {
|
||||
t.Errorf("want 5 entries for 6 nicks, got %q", reply)
|
||||
}
|
||||
|
||||
// A nick that cannot be looked up is an error, not a nick
|
||||
// with no session.
|
||||
cancelled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
_, err = env.svc.UserhostReply(cancelled, []string{"alice"}, "srv")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("want the lookup's error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// userModes is the stored state of a session's user mode
|
||||
// flags.
|
||||
type userModes struct {
|
||||
oper bool
|
||||
wallops bool
|
||||
}
|
||||
|
||||
// applyUserModeTest is one mode string, the flags stored
|
||||
// before it is applied, and the expected outcome. An empty
|
||||
// wantModes means the string is rejected with
|
||||
// ERR_UMODEUNKNOWNFLAG, and then after must equal before.
|
||||
type applyUserModeTest struct {
|
||||
modeStr string
|
||||
before userModes
|
||||
wantModes string
|
||||
after userModes
|
||||
}
|
||||
|
||||
func applyUserModeTests() []applyUserModeTest {
|
||||
none := userModes{}
|
||||
oper := userModes{oper: true}
|
||||
wallops := userModes{wallops: true}
|
||||
both := userModes{oper: true, wallops: true}
|
||||
|
||||
return []applyUserModeTest{
|
||||
{"+w", none, "+w", wallops},
|
||||
{"-w", wallops, "+", none},
|
||||
{"-o", oper, "+", none},
|
||||
{"-wo", both, "+", none},
|
||||
{"+w-o", oper, "+w", wallops},
|
||||
{"-w+w", none, "+w", wallops},
|
||||
{"+w-w", wallops, "+", none},
|
||||
{"-w+o", both, "", both},
|
||||
{"+o-w+w", both, "", both},
|
||||
{"+wo", none, "", none},
|
||||
{"+wz", none, "", none},
|
||||
{"+z", none, "", none},
|
||||
{"-x+y", none, "", none},
|
||||
{"+y-x", none, "", none},
|
||||
{"w", none, "", none},
|
||||
{"xw", wallops, "", wallops},
|
||||
{"", none, "", none},
|
||||
{"+", none, "", none},
|
||||
{"-", none, "", none},
|
||||
{"+-+", none, "", none},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserMode(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
for i, test := range applyUserModeTests() {
|
||||
t.Run(test.modeStr, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
sid := createSession(
|
||||
ctx, t, env.db, fmt.Sprintf("user%d", i),
|
||||
)
|
||||
setUserModes(ctx, t, env.db, sid, test.before)
|
||||
|
||||
modes, err := env.svc.ApplyUserMode(
|
||||
ctx, sid, test.modeStr,
|
||||
)
|
||||
checkApplyUserModeResult(t, test.wantModes, modes, err)
|
||||
|
||||
got := getUserModes(ctx, t, env.db, sid)
|
||||
if got != test.after {
|
||||
t.Errorf(
|
||||
"stored modes: want %+v, got %+v",
|
||||
test.after, got,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkApplyUserModeResult(
|
||||
t *testing.T,
|
||||
wantModes, modes string,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if wantModes == "" {
|
||||
var ircErr *service.IRCError
|
||||
if !errors.As(err, &ircErr) ||
|
||||
ircErr.Code != irc.ErrUmodeUnknownFlag {
|
||||
t.Fatalf("want ERR_UMODEUNKNOWNFLAG, got %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if modes != wantModes {
|
||||
t.Errorf("modes: want %q, got %q", wantModes, modes)
|
||||
}
|
||||
}
|
||||
|
||||
func setUserModes(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
sid int64,
|
||||
modes userModes,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
err := database.SetSessionUserModes(
|
||||
ctx, sid, &modes.wallops, &modes.oper,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("set user modes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserModes(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
sid int64,
|
||||
) userModes {
|
||||
t.Helper()
|
||||
|
||||
oper, err := database.IsSessionOper(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("read oper: %v", err)
|
||||
}
|
||||
|
||||
wallops, err := database.IsSessionWallops(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("read wallops: %v", err)
|
||||
}
|
||||
|
||||
return userModes{oper: oper, wallops: wallops}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ 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"
|
||||
@@ -23,12 +20,8 @@ const (
|
||||
CmdPong = "PONG"
|
||||
CmdPrivmsg = "PRIVMSG"
|
||||
CmdQuit = "QUIT"
|
||||
CmdTime = "TIME"
|
||||
CmdTopic = "TOPIC"
|
||||
CmdUser = "USER"
|
||||
CmdUserhost = "USERHOST"
|
||||
CmdVersion = "VERSION"
|
||||
CmdWallops = "WALLOPS"
|
||||
CmdWho = "WHO"
|
||||
CmdWhois = "WHOIS"
|
||||
)
|
||||
|
||||
+10
-4
@@ -2,16 +2,22 @@
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
NPX=""
|
||||
fi
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
||||
node_modules/.bin/esbuild src/app.jsx \
|
||||
${NPX:+$NPX} esbuild src/app.jsx \
|
||||
--bundle \
|
||||
--minify \
|
||||
--format=esm \
|
||||
|
||||
Generated
+513
@@ -0,0 +1,513 @@
|
||||
{
|
||||
"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
@@ -1,170 +0,0 @@
|
||||
# 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