Compare commits
1 Commits
feature/87
...
260f798af4
| Author | SHA1 | Date | |
|---|---|---|---|
| 260f798af4 |
@@ -2307,8 +2307,8 @@ IRC_LISTEN_ADDR=
|
||||
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||||
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||||
| Messaging | `PRIVMSG`, `NOTICE` |
|
||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
||||
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` |
|
||||
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||
|
||||
### Protocol Details
|
||||
|
||||
@@ -2820,10 +2820,6 @@ guess is borne by the server (bcrypt), not the client.
|
||||
login from additional devices via `POST /api/v1/login`
|
||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
||||
all API authentication
|
||||
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
|
||||
(256–259), INFO (371/374), TIME (391), KILL (oper-only forced
|
||||
disconnect), WALLOPS (oper-only broadcast to +w users)
|
||||
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
|
||||
|
||||
### Future (1.0+)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package main is the entry point for the neoirc-cli client.
|
||||
package main
|
||||
|
||||
import "sneak.berlin/go/neoirc/internal/cli"
|
||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
||||
|
||||
func main() {
|
||||
cli.Run()
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/handlers"
|
||||
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/middleware"
|
||||
"sneak.berlin/go/neoirc/internal/server"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module sneak.berlin/go/neoirc
|
||||
module git.eeqj.de/sneak/neoirc
|
||||
|
||||
go 1.24.0
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
)
|
||||
|
||||
func TestNewBroker(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||
)
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // .env
|
||||
_ "modernc.org/sqlite" // driver
|
||||
@@ -135,21 +135,13 @@ type migration struct {
|
||||
func (database *Database) runMigrations(
|
||||
ctx context.Context,
|
||||
) error {
|
||||
bootstrap, err := SchemaFiles.ReadFile(
|
||||
"schema/000.sql",
|
||||
)
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"read bootstrap migration: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
_, err = database.conn.ExecContext(
|
||||
ctx, string(bootstrap),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"execute bootstrap migration: %w", err,
|
||||
"create schema_migrations: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,11 +270,6 @@ func (database *Database) loadMigrations() (
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip bootstrap migration; it is executed separately.
|
||||
if version == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
content, readErr := SchemaFiles.ReadFile(
|
||||
"schema/" + entry.Name(),
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"github.com/google/uuid"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -2165,52 +2165,6 @@ func (database *Database) SetChannelSecret(
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- No External Messages (+n) ---
|
||||
|
||||
// IsChannelNoExternal checks if a channel has +n mode.
|
||||
func (database *Database) IsChannelNoExternal(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (bool, error) {
|
||||
var isNoExternal int
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT is_no_external FROM channels
|
||||
WHERE id = ?`,
|
||||
channelID,
|
||||
).Scan(&isNoExternal)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check no external: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return isNoExternal != 0, nil
|
||||
}
|
||||
|
||||
// SetChannelNoExternal sets or unsets +n mode.
|
||||
func (database *Database) SetChannelNoExternal(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
noExternal bool,
|
||||
) error {
|
||||
val := 0
|
||||
if noExternal {
|
||||
val = 1
|
||||
}
|
||||
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET is_no_external = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
val, time.Now(), channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set no external: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAllChannelsWithCountsFiltered returns all channels
|
||||
// with member counts, excluding secret channels that
|
||||
// the given session is not a member of.
|
||||
@@ -2413,132 +2367,3 @@ func (database *Database) SetChannelUserLimit(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionWallops sets the wallops (+w) flag on a
|
||||
// session.
|
||||
func (database *Database) SetSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
enabled bool,
|
||||
) error {
|
||||
val := 0
|
||||
if enabled {
|
||||
val = 1
|
||||
}
|
||||
|
||||
_, err := database.conn.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||
val, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set session wallops: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSessionWallops returns whether the session has the
|
||||
// wallops (+w) usermode set.
|
||||
func (database *Database) IsSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
var isWallops int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&isWallops)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check session wallops: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return isWallops != 0, nil
|
||||
}
|
||||
|
||||
// GetWallopsSessionIDs returns all session IDs that have
|
||||
// the wallops (+w) usermode set.
|
||||
func (database *Database) GetWallopsSessionIDs(
|
||||
ctx context.Context,
|
||||
) ([]int64, error) {
|
||||
rows, err := database.conn.QueryContext(
|
||||
ctx,
|
||||
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var ids []int64
|
||||
|
||||
for rows.Next() {
|
||||
var sessionID int64
|
||||
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"scan wallops session: %w", scanErr,
|
||||
)
|
||||
}
|
||||
|
||||
ids = append(ids, sessionID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"iterate wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UserhostInfo holds the data needed for RPL_USERHOST.
|
||||
type UserhostInfo struct {
|
||||
Nick string
|
||||
Username string
|
||||
Hostname string
|
||||
IsOper bool
|
||||
AwayMessage string
|
||||
}
|
||||
|
||||
// GetUserhostInfo returns USERHOST info for the given
|
||||
// nicks. Only nicks that exist are returned.
|
||||
func (database *Database) GetUserhostInfo(
|
||||
ctx context.Context,
|
||||
nicks []string,
|
||||
) ([]UserhostInfo, error) {
|
||||
if len(nicks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results := make([]UserhostInfo, 0, len(nicks))
|
||||
|
||||
for _, nick := range nicks {
|
||||
var info UserhostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT nick, username, hostname,
|
||||
is_oper, away_message
|
||||
FROM sessions WHERE nick = ?`,
|
||||
nick,
|
||||
).Scan(
|
||||
&info.Nick, &info.Username, &info.Hostname,
|
||||
&info.IsOper, &info.AwayMessage,
|
||||
)
|
||||
if err != nil {
|
||||
continue // nick not found, skip
|
||||
}
|
||||
|
||||
results = append(results, info)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Bootstrap: create the schema_migrations table itself.
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||
@@ -10,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 '',
|
||||
@@ -45,7 +44,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
||||
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||
is_no_external INTEGER NOT NULL DEFAULT 1,
|
||||
channel_key TEXT NOT NULL DEFAULT '',
|
||||
user_limit INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -969,12 +969,10 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdAway, irc.CmdNick,
|
||||
irc.CmdPass, irc.CmdInvite:
|
||||
hdlr.dispatchBodyOnlyCommand(
|
||||
case irc.CmdAway:
|
||||
hdlr.handleAway(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, bodyLines,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||
hdlr.handlePrivmsg(
|
||||
@@ -993,12 +991,27 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target, body,
|
||||
)
|
||||
case irc.CmdNick:
|
||||
hdlr.handleNick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPass:
|
||||
hdlr.handlePass(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdTopic:
|
||||
hdlr.handleTopic(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdInvite:
|
||||
hdlr.handleInvite(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdKick:
|
||||
hdlr.handleKick(
|
||||
writer, request,
|
||||
@@ -1009,15 +1022,12 @@ 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,
|
||||
irc.CmdVersion, irc.CmdAdmin,
|
||||
irc.CmdInfo, irc.CmdTime:
|
||||
case irc.CmdMotd, irc.CmdPing:
|
||||
hdlr.dispatchInfoCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
@@ -1072,11 +1082,6 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdUserhost:
|
||||
hdlr.handleUserhost(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
default:
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
@@ -1869,8 +1874,7 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
|
||||
}
|
||||
|
||||
// dispatchInfoCommand handles informational IRC commands
|
||||
// that produce server-side numerics (MOTD, PING,
|
||||
// VERSION, ADMIN, INFO, TIME).
|
||||
// that produce server-side numerics (MOTD, PING).
|
||||
func (hdlr *Handlers) dispatchInfoCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -1896,34 +1900,6 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
||||
},
|
||||
http.StatusOK)
|
||||
|
||||
return
|
||||
case irc.CmdVersion:
|
||||
hdlr.handleVersion(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
case irc.CmdAdmin:
|
||||
hdlr.handleAdmin(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
case irc.CmdInfo:
|
||||
hdlr.handleInfo(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
case irc.CmdTime:
|
||||
hdlr.handleTime(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1980,11 +1956,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{"status": "ok"},
|
||||
http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -2036,14 +2016,62 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
}
|
||||
|
||||
// buildChannelModeString constructs the current mode
|
||||
// string for a channel by delegating to the service
|
||||
// layer's QueryChannelMode, which returns the complete
|
||||
// mode string including all flags and parameters.
|
||||
// string for a channel, including +n (always on), +t, +m,
|
||||
// +i, +s, +k, +l, and +H with their parameters.
|
||||
func (hdlr *Handlers) buildChannelModeString(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
) string {
|
||||
return hdlr.svc.QueryChannelMode(ctx, chID)
|
||||
modes := "+n"
|
||||
|
||||
isInviteOnly, ioErr := hdlr.params.Database.
|
||||
IsChannelInviteOnly(ctx, chID)
|
||||
if ioErr == nil && isInviteOnly {
|
||||
modes += "i"
|
||||
}
|
||||
|
||||
isModerated, modErr := hdlr.params.Database.
|
||||
IsChannelModerated(ctx, chID)
|
||||
if modErr == nil && isModerated {
|
||||
modes += "m"
|
||||
}
|
||||
|
||||
isSecret, secErr := hdlr.params.Database.
|
||||
IsChannelSecret(ctx, chID)
|
||||
if secErr == nil && isSecret {
|
||||
modes += "s"
|
||||
}
|
||||
|
||||
isTopicLocked, tlErr := hdlr.params.Database.
|
||||
IsChannelTopicLocked(ctx, chID)
|
||||
if tlErr == nil && isTopicLocked {
|
||||
modes += "t"
|
||||
}
|
||||
|
||||
var modeParams string
|
||||
|
||||
key, keyErr := hdlr.params.Database.
|
||||
GetChannelKey(ctx, chID)
|
||||
if keyErr == nil && key != "" {
|
||||
modes += "k"
|
||||
modeParams += " " + key
|
||||
}
|
||||
|
||||
limit, limErr := hdlr.params.Database.
|
||||
GetChannelUserLimit(ctx, chID)
|
||||
if limErr == nil && limit > 0 {
|
||||
modes += "l"
|
||||
modeParams += " " + strconv.Itoa(limit)
|
||||
}
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.
|
||||
GetChannelHashcashBits(ctx, chID)
|
||||
if bitsErr == nil && bits > 0 {
|
||||
modes += "H"
|
||||
modeParams += " " + strconv.Itoa(bits)
|
||||
}
|
||||
|
||||
return modes + modeParams
|
||||
}
|
||||
|
||||
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||
|
||||
@@ -18,21 +18,21 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/handlers"
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/middleware"
|
||||
"sneak.berlin/go/neoirc/internal/server"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const minPasswordLength = 8
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
var errUnauthorized = errors.New("unauthorized")
|
||||
|
||||
@@ -1,659 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// maxUserhostNicks is the maximum number of nicks allowed
|
||||
// in a single USERHOST query (RFC 2812).
|
||||
const maxUserhostNicks = 5
|
||||
|
||||
// dispatchBodyOnlyCommand routes commands that take
|
||||
// (writer, request, sessionID, clientID, nick, bodyLines).
|
||||
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, command string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdAway:
|
||||
hdlr.handleAway(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdNick:
|
||||
hdlr.handleNick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPass:
|
||||
hdlr.handlePass(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdInvite:
|
||||
hdlr.handleInvite(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchOperCommand routes oper-related commands (OPER,
|
||||
// KILL, WALLOPS) to their handlers.
|
||||
func (hdlr *Handlers) dispatchOperCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, command string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdOper:
|
||||
hdlr.handleOper(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdKill:
|
||||
hdlr.handleKill(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdWallops:
|
||||
hdlr.handleWallops(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserhost handles the USERHOST command.
|
||||
// Returns user@host info for up to 5 nicks.
|
||||
func (hdlr *Handlers) handleUserhost(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdUserhost},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Limit to 5 nicks per RFC 2812.
|
||||
nicks := lines
|
||||
if len(nicks) > maxUserhostNicks {
|
||||
nicks = nicks[:maxUserhostNicks]
|
||||
}
|
||||
|
||||
infos, err := hdlr.params.Database.GetUserhostInfo(
|
||||
ctx, nicks,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"userhost query failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
replyStr := hdlr.buildUserhostReply(infos)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUserHost, nick, nil,
|
||||
replyStr,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// buildUserhostReply builds the RPL_USERHOST reply
|
||||
// string per RFC 2812.
|
||||
func (hdlr *Handlers) buildUserhostReply(
|
||||
infos []db.UserhostInfo,
|
||||
) string {
|
||||
replies := make([]string, 0, len(infos))
|
||||
|
||||
for idx := range infos {
|
||||
info := &infos[idx]
|
||||
|
||||
username := info.Username
|
||||
if username == "" {
|
||||
username = info.Nick
|
||||
}
|
||||
|
||||
hostname := info.Hostname
|
||||
if hostname == "" {
|
||||
hostname = hdlr.serverName()
|
||||
}
|
||||
|
||||
operStar := ""
|
||||
if info.IsOper {
|
||||
operStar = "*"
|
||||
}
|
||||
|
||||
awayPrefix := "+"
|
||||
if info.AwayMessage != "" {
|
||||
awayPrefix = "-"
|
||||
}
|
||||
|
||||
replies = append(replies,
|
||||
info.Nick+operStar+"="+
|
||||
awayPrefix+username+"@"+hostname,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Join(replies, " ")
|
||||
}
|
||||
|
||||
// handleVersion handles the VERSION command.
|
||||
// Returns the server version string.
|
||||
func (hdlr *Handlers) handleVersion(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
version := hdlr.serverVersion()
|
||||
|
||||
// 351 RPL_VERSION
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplVersion, nick,
|
||||
[]string{version + ".", srvName},
|
||||
"",
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleAdmin handles the ADMIN command.
|
||||
// Returns server admin contact info.
|
||||
func (hdlr *Handlers) handleAdmin(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
|
||||
// 256 RPL_ADMINME
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminMe, nick,
|
||||
[]string{srvName},
|
||||
"Administrative info",
|
||||
)
|
||||
|
||||
// 257 RPL_ADMINLOC1
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
||||
"neoirc server",
|
||||
)
|
||||
|
||||
// 258 RPL_ADMINLOC2
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
||||
"IRC over HTTP",
|
||||
)
|
||||
|
||||
// 259 RPL_ADMINEMAIL
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
||||
"admin@"+srvName,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleInfo handles the INFO command.
|
||||
// Returns server software information.
|
||||
func (hdlr *Handlers) handleInfo(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
version := hdlr.serverVersion()
|
||||
|
||||
infoLines := []string{
|
||||
"neoirc — IRC semantics over HTTP",
|
||||
"Version: " + version,
|
||||
"Written in Go",
|
||||
"Started: " +
|
||||
hdlr.params.Globals.StartTime.
|
||||
Format(time.RFC1123),
|
||||
}
|
||||
|
||||
for _, line := range infoLines {
|
||||
// 371 RPL_INFO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplInfo, nick, nil,
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
// 374 RPL_ENDOFINFO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
||||
"End of /INFO list",
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleTime handles the TIME command.
|
||||
// Returns the server's local time in RFC format.
|
||||
func (hdlr *Handlers) handleTime(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
srvName := hdlr.serverName()
|
||||
|
||||
// 391 RPL_TIME
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplTime, nick,
|
||||
[]string{srvName},
|
||||
time.Now().Format(time.RFC1123),
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleKill handles the KILL command.
|
||||
// Forcibly disconnects a user (oper only).
|
||||
func (hdlr *Handlers) handleKill(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Check oper status.
|
||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoPrivileges, nick, nil,
|
||||
"Permission Denied- You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
|
||||
var targetNick string
|
||||
if len(lines) > 0 {
|
||||
targetNick = strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
if targetNick == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdKill},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reason := "KILLed"
|
||||
if len(lines) > 1 {
|
||||
reason = lines[1]
|
||||
}
|
||||
|
||||
targetSID, lookupErr := hdlr.params.Database.
|
||||
GetSessionByNick(ctx, targetNick)
|
||||
if lookupErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchNick, nick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Do not allow killing yourself.
|
||||
if targetSID == sessionID {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCantKillServer, nick, nil,
|
||||
"You cannot KILL yourself",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||
|
||||
hdlr.svc.BroadcastQuit(
|
||||
request.Context(), targetSID,
|
||||
targetNick, quitReason,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleWallops handles the WALLOPS command.
|
||||
// Broadcasts a message to all users with +w usermode
|
||||
// (oper only).
|
||||
func (hdlr *Handlers) handleWallops(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Check oper status.
|
||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoPrivileges, nick, nil,
|
||||
"Permission Denied- You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdWallops},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.Join(lines, " ")
|
||||
|
||||
wallopsSIDs, err := hdlr.params.Database.
|
||||
GetWallopsSessionIDs(ctx)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"get wallops sessions failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(wallopsSIDs) > 0 {
|
||||
body, mErr := json.Marshal([]string{message})
|
||||
if mErr != nil {
|
||||
hdlr.log.Error(
|
||||
"marshal wallops body", "error", mErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdWallops, nick, "*",
|
||||
json.RawMessage(body), wallopsSIDs,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleUserMode handles user mode queries and changes
|
||||
// (e.g., MODE nick, MODE nick +w).
|
||||
func (hdlr *Handlers) handleUserMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, target string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
lines := bodyLines()
|
||||
|
||||
// Mode change requested.
|
||||
if len(lines) > 0 {
|
||||
// Users can only change their own modes.
|
||||
if target != nick && target != "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUsersDoNotMatch, nick, nil,
|
||||
"Can't change mode for other users",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.applyUserModeChange(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, lines[0],
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Mode query — build the current mode string.
|
||||
modeStr := hdlr.buildUserModeString(ctx, sessionID)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||
modeStr,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// buildUserModeString constructs the mode string for a
|
||||
// user (e.g., "+ow" for oper+wallops).
|
||||
func (hdlr *Handlers) buildUserModeString(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) string {
|
||||
modes := "+"
|
||||
|
||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err == nil && isOper {
|
||||
modes += "o"
|
||||
}
|
||||
|
||||
isWallops, err := hdlr.params.Database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err == nil && isWallops {
|
||||
modes += "w"
|
||||
}
|
||||
|
||||
return modes
|
||||
}
|
||||
|
||||
// applyUserModeChange applies a user mode change string
|
||||
// (e.g., "+w", "-w").
|
||||
func (hdlr *Handlers) applyUserModeChange(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, modeStr string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
adding := modeStr[0] == '+'
|
||||
modeChar := modeStr[1:]
|
||||
|
||||
applied, err := hdlr.applyModeChar(
|
||||
ctx, writer, request,
|
||||
sessionID, clientID, nick,
|
||||
modeChar, adding,
|
||||
)
|
||||
if err != nil || !applied {
|
||||
return
|
||||
}
|
||||
|
||||
newModes := hdlr.buildUserModeString(ctx, sessionID)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||
newModes,
|
||||
)
|
||||
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// applyModeChar applies a single user mode character.
|
||||
// Returns (applied, error).
|
||||
func (hdlr *Handlers) applyModeChar(
|
||||
ctx context.Context,
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, modeChar string,
|
||||
adding bool,
|
||||
) (bool, error) {
|
||||
switch modeChar {
|
||||
case "w":
|
||||
err := hdlr.params.Database.SetSessionWallops(
|
||||
ctx, sessionID, adding,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set wallops mode failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return false, fmt.Errorf(
|
||||
"set wallops: %w", err,
|
||||
)
|
||||
}
|
||||
case "o":
|
||||
// +o cannot be set via MODE, only via OPER.
|
||||
if adding {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err := hdlr.params.Database.SetSessionOper(
|
||||
ctx, sessionID, false,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"clear oper mode failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return false, fmt.Errorf(
|
||||
"clear oper: %w", err,
|
||||
)
|
||||
}
|
||||
default:
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,982 +0,0 @@
|
||||
// Tests for Tier 3 utility IRC commands: USERHOST,
|
||||
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
|
||||
//
|
||||
//nolint:paralleltest
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- USERHOST ---
|
||||
|
||||
func TestUserhostSingleNick(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("alice")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "USERHOST",
|
||||
bodyKey: []string{"alice"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 302 RPL_USERHOST.
|
||||
msg := findNumericWithParams(msgs, "302")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_USERHOST (302), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// Body should contain "alice" with the
|
||||
// nick=+user@host format.
|
||||
body := getNumericBody(msg)
|
||||
if !strings.Contains(body, "alice") {
|
||||
t.Fatalf(
|
||||
"expected body to contain 'alice', got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
// '+' means not away.
|
||||
if !strings.Contains(body, "=+") {
|
||||
t.Fatalf(
|
||||
"expected not-away prefix '=+', got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserhostMultipleNicks(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token1 := tserver.createSession("bob")
|
||||
token2 := tserver.createSession("carol")
|
||||
|
||||
_ = token2
|
||||
|
||||
_, lastID := tserver.pollMessages(token1, 0)
|
||||
|
||||
tserver.sendCommand(token1, map[string]any{
|
||||
commandKey: "USERHOST",
|
||||
bodyKey: []string{"bob", "carol"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token1, lastID)
|
||||
|
||||
msg := findNumericWithParams(msgs, "302")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_USERHOST (302), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body := getNumericBody(msg)
|
||||
if !strings.Contains(body, "bob") {
|
||||
t.Fatalf(
|
||||
"expected body to contain 'bob', got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "carol") {
|
||||
t.Fatalf(
|
||||
"expected body to contain 'carol', got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserhostNonexistentNick(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("dave")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "USERHOST",
|
||||
bodyKey: []string{"nobody"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Should still get 302 but with empty body.
|
||||
msg := findNumericWithParams(msgs, "302")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_USERHOST (302), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserhostNoParams(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("eve")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "USERHOST",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserhostShowsOper(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
token := tserver.createSession("opernick")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Authenticate as oper.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
// USERHOST should show '*' for oper.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "USERHOST",
|
||||
bodyKey: []string{"opernick"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
msg := findNumericWithParams(msgs, "302")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_USERHOST (302), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body := getNumericBody(msg)
|
||||
if !strings.Contains(body, "opernick*=") {
|
||||
t.Fatalf(
|
||||
"expected oper '*' in reply, got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserhostShowsAway(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("awaynick")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Set away.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "AWAY",
|
||||
bodyKey: []string{"gone fishing"},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
// USERHOST should show '-' for away.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "USERHOST",
|
||||
bodyKey: []string{"awaynick"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
msg := findNumericWithParams(msgs, "302")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_USERHOST (302), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body := getNumericBody(msg)
|
||||
if !strings.Contains(body, "=-") {
|
||||
t.Fatalf(
|
||||
"expected away prefix '=-' in reply, got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- VERSION ---
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("frank")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "VERSION",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 351 RPL_VERSION.
|
||||
msg := findNumericWithParams(msgs, "351")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_VERSION (351), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
params := getNumericParams(msg)
|
||||
if len(params) == 0 {
|
||||
t.Fatal("expected VERSION params, got none")
|
||||
}
|
||||
|
||||
// First param should contain version string.
|
||||
if !strings.Contains(params[0], "test") {
|
||||
t.Fatalf(
|
||||
"expected version to contain 'test', got %q",
|
||||
params[0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADMIN ---
|
||||
|
||||
func TestAdmin(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("grace")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "ADMIN",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 256 RPL_ADMINME.
|
||||
if !findNumeric(msgs, "256") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ADMINME (256), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// Expect 257 RPL_ADMINLOC1.
|
||||
if !findNumeric(msgs, "257") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ADMINLOC1 (257), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// Expect 258 RPL_ADMINLOC2.
|
||||
if !findNumeric(msgs, "258") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ADMINLOC2 (258), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// Expect 259 RPL_ADMINEMAIL.
|
||||
if !findNumeric(msgs, "259") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ADMINEMAIL (259), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- INFO ---
|
||||
|
||||
func TestInfo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("hank")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "INFO",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 371 RPL_INFO (at least one).
|
||||
if !findNumeric(msgs, "371") {
|
||||
t.Fatalf(
|
||||
"expected RPL_INFO (371), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// Expect 374 RPL_ENDOFINFO.
|
||||
if !findNumeric(msgs, "374") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ENDOFINFO (374), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- TIME ---
|
||||
|
||||
func TestTime(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("iris")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TIME",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 391 RPL_TIME.
|
||||
msg := findNumericWithParams(msgs, "391")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_TIME (391), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- KILL ---
|
||||
|
||||
func TestKillSuccess(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
// Create the victim first.
|
||||
victimToken := tserver.createSession("victim")
|
||||
_ = victimToken
|
||||
|
||||
// Create oper user.
|
||||
operToken := tserver.createSession("killer")
|
||||
_, lastID := tserver.pollMessages(operToken, 0)
|
||||
|
||||
// Authenticate as oper.
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(operToken, lastID)
|
||||
|
||||
// Kill the victim.
|
||||
status, result := tserver.sendCommand(
|
||||
operToken, map[string]any{
|
||||
commandKey: "KILL",
|
||||
bodyKey: []string{"victim", "go away"},
|
||||
},
|
||||
)
|
||||
|
||||
if status != 200 {
|
||||
t.Fatalf("expected 200, got %d: %v", status, result)
|
||||
}
|
||||
|
||||
resultStatus, _ := result[statusKey].(string)
|
||||
if resultStatus != "ok" {
|
||||
t.Fatalf(
|
||||
"expected status ok, got %v",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the victim's session is gone by trying
|
||||
// to WHOIS them.
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "WHOIS",
|
||||
toKey: "victim",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(operToken, lastID)
|
||||
|
||||
// Should get 401 ERR_NOSUCHNICK.
|
||||
if !findNumeric(msgs, "401") {
|
||||
t.Fatalf(
|
||||
"expected victim to be gone (401), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillNotOper(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
_ = tserver.createSession("target")
|
||||
|
||||
token := tserver.createSession("notoper")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "KILL",
|
||||
bodyKey: []string{"target", "no reason"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 481 ERR_NOPRIVILEGES.
|
||||
if !findNumeric(msgs, "481") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOPRIVILEGES (481), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillNoParams(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
token := tserver.createSession("opertest")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "KILL",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// sendOperKillCommand is a helper that creates an oper
|
||||
// session, authenticates, then sends KILL with the given
|
||||
// target nick, and returns the resulting messages.
|
||||
func sendOperKillCommand(
|
||||
t *testing.T,
|
||||
tserver *testServer,
|
||||
operNick, targetNick string,
|
||||
) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
token := tserver.createSession(operNick)
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "KILL",
|
||||
bodyKey: []string{targetNick},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
func TestKillNonexistentUser(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
msgs := sendOperKillCommand(
|
||||
t, tserver, "opertest2", "ghost",
|
||||
)
|
||||
|
||||
// Expect 401 ERR_NOSUCHNICK.
|
||||
if !findNumeric(msgs, "401") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOSUCHNICK (401), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillSelf(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
msgs := sendOperKillCommand(
|
||||
t, tserver, "selfkiller", "selfkiller",
|
||||
)
|
||||
|
||||
// Expect 483 ERR_CANTKILLSERVER.
|
||||
if !findNumeric(msgs, "483") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANTKILLSERVER (483), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillBroadcastsQuit(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
// Create victim and join a channel.
|
||||
victimToken := tserver.createSession("vuser")
|
||||
|
||||
tserver.sendCommand(victimToken, map[string]any{
|
||||
commandKey: joinCmd,
|
||||
toKey: "#killtest",
|
||||
})
|
||||
|
||||
// Create observer and join same channel.
|
||||
observerToken := tserver.createSession("observer")
|
||||
|
||||
tserver.sendCommand(observerToken, map[string]any{
|
||||
commandKey: joinCmd,
|
||||
toKey: "#killtest",
|
||||
})
|
||||
|
||||
_, lastObs := tserver.pollMessages(observerToken, 0)
|
||||
|
||||
// Create oper.
|
||||
operToken := tserver.createSession("theoper2")
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
// Kill the victim.
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "KILL",
|
||||
bodyKey: []string{"vuser", "testing kill"},
|
||||
})
|
||||
|
||||
// Observer should see a QUIT message.
|
||||
msgs, _ := tserver.pollMessages(observerToken, lastObs)
|
||||
|
||||
foundQuit := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
cmd, _ := msg["command"].(string)
|
||||
if cmd == "QUIT" {
|
||||
from, _ := msg["from"].(string)
|
||||
if from == "vuser" {
|
||||
foundQuit = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundQuit {
|
||||
t.Fatalf(
|
||||
"expected QUIT from vuser, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- WALLOPS ---
|
||||
|
||||
func TestWallopsSuccess(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
// Create receiver with +w.
|
||||
receiverToken := tserver.createSession("receiver")
|
||||
|
||||
tserver.sendCommand(receiverToken, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "receiver",
|
||||
bodyKey: []string{"+w"},
|
||||
})
|
||||
|
||||
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
||||
|
||||
// Create oper.
|
||||
operToken := tserver.createSession("walloper")
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
// Also set +w on oper so they receive it too.
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "walloper",
|
||||
bodyKey: []string{"+w"},
|
||||
})
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
// Send WALLOPS.
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "WALLOPS",
|
||||
bodyKey: []string{"server going down"},
|
||||
})
|
||||
|
||||
// Receiver should get the WALLOPS message.
|
||||
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
||||
|
||||
foundWallops := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
cmd, _ := msg["command"].(string)
|
||||
if cmd == "WALLOPS" {
|
||||
foundWallops = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundWallops {
|
||||
t.Fatalf(
|
||||
"expected WALLOPS message, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallopsNotOper(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("notoper2")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "WALLOPS",
|
||||
bodyKey: []string{"hello"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 481 ERR_NOPRIVILEGES.
|
||||
if !findNumeric(msgs, "481") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOPRIVILEGES (481), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallopsNoParams(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
token := tserver.createSession("operempty")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "WALLOPS",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallopsNotReceivedWithoutW(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
// Create receiver WITHOUT +w.
|
||||
receiverToken := tserver.createSession("nowallops")
|
||||
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
||||
|
||||
// Create oper.
|
||||
operToken := tserver.createSession("walloper2")
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
tserver.pollMessages(operToken, 0)
|
||||
|
||||
// Send WALLOPS.
|
||||
tserver.sendCommand(operToken, map[string]any{
|
||||
commandKey: "WALLOPS",
|
||||
bodyKey: []string{"secret message"},
|
||||
})
|
||||
|
||||
// Receiver should NOT get the WALLOPS message.
|
||||
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
||||
|
||||
for _, msg := range msgs {
|
||||
cmd, _ := msg["command"].(string)
|
||||
if cmd == "WALLOPS" {
|
||||
t.Fatalf(
|
||||
"did not expect WALLOPS for user "+
|
||||
"without +w, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Mode +w ---
|
||||
|
||||
func TestUserModeSetW(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("wmoder")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Set +w.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "wmoder",
|
||||
bodyKey: []string{"+w"},
|
||||
})
|
||||
|
||||
msgs, lastID := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 221 RPL_UMODEIS with "+w".
|
||||
msg := findNumericWithParams(msgs, "221")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_UMODEIS (221), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body := getNumericBody(msg)
|
||||
if !strings.Contains(body, "w") {
|
||||
t.Fatalf(
|
||||
"expected mode string to contain 'w', got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
// Now query mode.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "wmoder",
|
||||
})
|
||||
|
||||
msgs, _ = tserver.pollMessages(token, lastID)
|
||||
|
||||
msg = findNumericWithParams(msgs, "221")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_UMODEIS (221) on query, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body = getNumericBody(msg)
|
||||
if !strings.Contains(body, "w") {
|
||||
t.Fatalf(
|
||||
"expected mode '+w' in query, got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeUnsetW(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("wunsetter")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Set +w first.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "wunsetter",
|
||||
bodyKey: []string{"+w"},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
// Unset -w.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "wunsetter",
|
||||
bodyKey: []string{"-w"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
msg := findNumericWithParams(msgs, "221")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_UMODEIS (221), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body := getNumericBody(msg)
|
||||
if strings.Contains(body, "w") {
|
||||
t.Fatalf(
|
||||
"expected 'w' to be removed, got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeUnknownFlag(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("badmode")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "badmode",
|
||||
bodyKey: []string{"+z"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
||||
if !findNumeric(msgs, "501") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeCannotSetO(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("tryoper")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to set +o via MODE (should fail).
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "tryoper",
|
||||
bodyKey: []string{"+o"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
||||
if !findNumeric(msgs, "501") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeDeoper(t *testing.T) {
|
||||
tserver := newTestServerWithOper(t)
|
||||
|
||||
token := tserver.createSession("deoper")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Authenticate as oper.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "OPER",
|
||||
bodyKey: []string{testOperName, testOperPassword},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(token, lastID)
|
||||
|
||||
// Use MODE -o to de-oper.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "deoper",
|
||||
bodyKey: []string{"-o"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
msg := findNumericWithParams(msgs, "221")
|
||||
if msg == nil {
|
||||
t.Fatalf(
|
||||
"expected RPL_UMODEIS (221), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
body := getNumericBody(msg)
|
||||
if strings.Contains(body, "o") {
|
||||
t.Fatalf(
|
||||
"expected 'o' to be removed, got %q",
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeCannotChangeOtherUser(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
_ = tserver.createSession("other")
|
||||
|
||||
token := tserver.createSession("changer")
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try to change another user's mode.
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "MODE",
|
||||
toKey: "other",
|
||||
bodyKey: []string{"+w"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
// Expect 502 ERR_USERSDONTMATCH.
|
||||
if !findNumeric(msgs, "502") {
|
||||
t.Fatalf(
|
||||
"expected ERR_USERSDONTMATCH (502), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// getNumericBody extracts the body text from a numeric
|
||||
// message. The body is stored as a JSON array; this
|
||||
// returns the first element.
|
||||
func getNumericBody(msg map[string]any) string {
|
||||
raw, exists := msg["body"]
|
||||
if !exists || raw == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
arr, isArr := raw.([]any)
|
||||
if !isArr || len(arr) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
str, isStr := arr[0].(string)
|
||||
if !isStr {
|
||||
return ""
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const testBits = 2
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating a Healthcheck.
|
||||
|
||||
@@ -8,29 +8,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// versionString returns the server version for IRC
|
||||
// responses, falling back to "neoirc-dev" when globals
|
||||
// are not set (e.g. during tests).
|
||||
func versionString() string {
|
||||
name := globals.Appname
|
||||
ver := globals.Version
|
||||
|
||||
if name == "" {
|
||||
name = "neoirc"
|
||||
}
|
||||
|
||||
if ver == "" {
|
||||
ver = "dev"
|
||||
}
|
||||
|
||||
return name + "-" + ver
|
||||
}
|
||||
|
||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||
// reply on the wire.
|
||||
func (c *Conn) sendIRCError(err error) {
|
||||
@@ -450,7 +431,7 @@ func (c *Conn) handleMode(
|
||||
if strings.HasPrefix(target, "#") {
|
||||
c.handleChannelMode(ctx, msg)
|
||||
} else {
|
||||
c.handleUserMode(ctx, msg)
|
||||
c.handleUserMode(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,124 +490,6 @@ func (c *Conn) handleChannelMode(
|
||||
)
|
||||
}
|
||||
|
||||
// modeResult holds the delta strings produced by a
|
||||
// single mode-char application.
|
||||
type modeResult struct {
|
||||
applied string
|
||||
appliedArgs string
|
||||
consumed int
|
||||
skip bool
|
||||
}
|
||||
|
||||
// applyHashcashMode handles +H/-H (hashcash difficulty).
|
||||
func (c *Conn) applyHashcashMode(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
adding bool,
|
||||
args []string,
|
||||
argIdx int,
|
||||
) modeResult {
|
||||
if !adding {
|
||||
_ = c.database.SetChannelHashcashBits(
|
||||
ctx, chID, 0,
|
||||
)
|
||||
|
||||
return modeResult{
|
||||
applied: "-H",
|
||||
appliedArgs: "",
|
||||
consumed: 0,
|
||||
skip: false,
|
||||
}
|
||||
}
|
||||
|
||||
if argIdx >= len(args) {
|
||||
return modeResult{
|
||||
applied: "",
|
||||
appliedArgs: "",
|
||||
consumed: 0,
|
||||
skip: true,
|
||||
}
|
||||
}
|
||||
|
||||
bitsStr := args[argIdx]
|
||||
|
||||
bits, parseErr := strconv.Atoi(bitsStr)
|
||||
if parseErr != nil ||
|
||||
bits < 1 || bits > maxHashcashBits {
|
||||
c.sendNumeric(
|
||||
irc.ErrUnknownMode, "H",
|
||||
"is unknown mode char to me",
|
||||
)
|
||||
|
||||
return modeResult{
|
||||
applied: "",
|
||||
appliedArgs: "",
|
||||
consumed: 1,
|
||||
skip: true,
|
||||
}
|
||||
}
|
||||
|
||||
_ = c.database.SetChannelHashcashBits(
|
||||
ctx, chID, bits,
|
||||
)
|
||||
|
||||
return modeResult{
|
||||
applied: "+H",
|
||||
appliedArgs: " " + bitsStr,
|
||||
consumed: 1,
|
||||
skip: false,
|
||||
}
|
||||
}
|
||||
|
||||
// applyMemberMode handles +o/-o and +v/-v.
|
||||
func (c *Conn) applyMemberMode(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
channel string,
|
||||
modeChar rune,
|
||||
adding bool,
|
||||
args []string,
|
||||
argIdx int,
|
||||
) modeResult {
|
||||
if argIdx >= len(args) {
|
||||
return modeResult{
|
||||
applied: "",
|
||||
appliedArgs: "",
|
||||
consumed: 0,
|
||||
skip: true,
|
||||
}
|
||||
}
|
||||
|
||||
targetNick := args[argIdx]
|
||||
|
||||
err := c.svc.ApplyMemberMode(
|
||||
ctx, chID, channel,
|
||||
targetNick, modeChar, adding,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
|
||||
return modeResult{
|
||||
applied: "",
|
||||
appliedArgs: "",
|
||||
consumed: 1,
|
||||
skip: true,
|
||||
}
|
||||
}
|
||||
|
||||
prefix := "+"
|
||||
if !adding {
|
||||
prefix = "-"
|
||||
}
|
||||
|
||||
return modeResult{
|
||||
applied: prefix + string(modeChar),
|
||||
appliedArgs: " " + targetNick,
|
||||
consumed: 1,
|
||||
skip: false,
|
||||
}
|
||||
}
|
||||
|
||||
// applyChannelModes applies mode changes using the
|
||||
// service for individual mode operations.
|
||||
func (c *Conn) applyChannelModes(
|
||||
@@ -642,57 +505,52 @@ func (c *Conn) applyChannelModes(
|
||||
appliedArgs := ""
|
||||
|
||||
for _, modeChar := range modeStr {
|
||||
var res modeResult
|
||||
|
||||
switch modeChar {
|
||||
case '+':
|
||||
adding = true
|
||||
|
||||
continue
|
||||
case '-':
|
||||
adding = false
|
||||
|
||||
continue
|
||||
case 'i', 'm', 'n', 's', 't':
|
||||
case 'm', 't':
|
||||
_ = c.svc.SetChannelFlag(
|
||||
ctx, chID, modeChar, adding,
|
||||
)
|
||||
|
||||
prefix := "+"
|
||||
if !adding {
|
||||
prefix = "-"
|
||||
if adding {
|
||||
applied += "+" + string(modeChar)
|
||||
} else {
|
||||
applied += "-" + string(modeChar)
|
||||
}
|
||||
case 'o', 'v':
|
||||
if argIdx >= len(args) {
|
||||
break
|
||||
}
|
||||
|
||||
res = modeResult{
|
||||
applied: prefix + string(modeChar),
|
||||
appliedArgs: "",
|
||||
consumed: 0,
|
||||
skip: false,
|
||||
}
|
||||
case 'H':
|
||||
res = c.applyHashcashMode(
|
||||
ctx, chID, adding, args, argIdx,
|
||||
)
|
||||
case 'o', 'v':
|
||||
res = c.applyMemberMode(
|
||||
targetNick := args[argIdx]
|
||||
argIdx++
|
||||
|
||||
err := c.svc.ApplyMemberMode(
|
||||
ctx, chID, channel,
|
||||
modeChar, adding, args, argIdx,
|
||||
targetNick, modeChar, adding,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if adding {
|
||||
applied += "+" + string(modeChar)
|
||||
} else {
|
||||
applied += "-" + string(modeChar)
|
||||
}
|
||||
|
||||
appliedArgs += " " + targetNick
|
||||
default:
|
||||
c.sendNumeric(
|
||||
irc.ErrUnknownMode,
|
||||
string(modeChar),
|
||||
"is unknown mode char to me",
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
argIdx += res.consumed
|
||||
|
||||
if !res.skip {
|
||||
applied += res.applied
|
||||
appliedArgs += res.appliedArgs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,10 +571,7 @@ func (c *Conn) applyChannelModes(
|
||||
}
|
||||
|
||||
// handleUserMode handles MODE for users.
|
||||
func (c *Conn) handleUserMode(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
func (c *Conn) handleUserMode(msg *Message) {
|
||||
target := msg.Params[0]
|
||||
|
||||
if !strings.EqualFold(target, c.nick) {
|
||||
@@ -728,85 +583,8 @@ func (c *Conn) handleUserMode(
|
||||
return
|
||||
}
|
||||
|
||||
// Mode query (no mode string).
|
||||
if len(msg.Params) < 2 { //nolint:mnd
|
||||
c.sendNumeric(
|
||||
irc.RplUmodeIs,
|
||||
c.buildUmodeString(ctx),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
modeStr := msg.Params[1]
|
||||
|
||||
if len(modeStr) < 2 { //nolint:mnd
|
||||
c.sendNumeric(
|
||||
irc.ErrUmodeUnknownFlag,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
adding := modeStr[0] == '+'
|
||||
|
||||
for _, ch := range modeStr[1:] {
|
||||
switch ch {
|
||||
case 'w':
|
||||
_ = c.database.SetSessionWallops(
|
||||
ctx, c.sessionID, adding,
|
||||
)
|
||||
case 'o':
|
||||
if adding {
|
||||
c.sendNumeric(
|
||||
irc.ErrUmodeUnknownFlag,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.database.SetSessionOper(
|
||||
ctx, c.sessionID, false,
|
||||
)
|
||||
default:
|
||||
c.sendNumeric(
|
||||
irc.ErrUmodeUnknownFlag,
|
||||
"Unknown MODE flag",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplUmodeIs,
|
||||
c.buildUmodeString(ctx),
|
||||
)
|
||||
}
|
||||
|
||||
// buildUmodeString returns the current user mode string.
|
||||
func (c *Conn) buildUmodeString(
|
||||
ctx context.Context,
|
||||
) string {
|
||||
modes := "+"
|
||||
|
||||
isOper, err := c.database.IsSessionOper(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err == nil && isOper {
|
||||
modes += "o"
|
||||
}
|
||||
|
||||
isWallops, err := c.database.IsSessionWallops(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err == nil && isWallops {
|
||||
modes += "w"
|
||||
}
|
||||
|
||||
return modes
|
||||
// We don't support user modes beyond the basics.
|
||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
||||
}
|
||||
|
||||
// handleNames replies with channel member list.
|
||||
@@ -1398,191 +1176,3 @@ func (c *Conn) handleUserhost(
|
||||
strings.Join(replies, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// handleVersion replies with the server version string.
|
||||
func (c *Conn) handleVersion(ctx context.Context) {
|
||||
_ = ctx
|
||||
|
||||
version := versionString()
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplVersion,
|
||||
version+".", c.cfg.ServerName,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
// handleAdmin replies with server admin info.
|
||||
func (c *Conn) handleAdmin(ctx context.Context) {
|
||||
_ = ctx
|
||||
|
||||
srvName := c.cfg.ServerName
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminMe,
|
||||
srvName, "Administrative info",
|
||||
)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminLoc1,
|
||||
"neoirc server",
|
||||
)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminLoc2,
|
||||
"IRC over HTTP",
|
||||
)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminEmail,
|
||||
"admin@"+srvName,
|
||||
)
|
||||
}
|
||||
|
||||
// handleInfo replies with server software info.
|
||||
func (c *Conn) handleInfo(ctx context.Context) {
|
||||
_ = ctx
|
||||
|
||||
infoLines := []string{
|
||||
"neoirc — IRC semantics over HTTP",
|
||||
"Version: " + versionString(),
|
||||
"Written in Go",
|
||||
}
|
||||
|
||||
for _, line := range infoLines {
|
||||
c.sendNumeric(irc.RplInfo, line)
|
||||
}
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplEndOfInfo,
|
||||
"End of /INFO list",
|
||||
)
|
||||
}
|
||||
|
||||
// handleTime replies with the server's current time.
|
||||
func (c *Conn) handleTime(ctx context.Context) {
|
||||
_ = ctx
|
||||
|
||||
srvName := c.cfg.ServerName
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplTime,
|
||||
srvName, time.Now().Format(time.RFC1123),
|
||||
)
|
||||
}
|
||||
|
||||
// handleKillCmd forcibly disconnects a target user (oper
|
||||
// only).
|
||||
func (c *Conn) handleKillCmd(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
isOper, err := c.database.IsSessionOper(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoPrivileges,
|
||||
"Permission Denied- "+
|
||||
"You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
"KILL", "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetNick := msg.Params[0]
|
||||
|
||||
reason := "KILLed"
|
||||
if len(msg.Params) > 1 {
|
||||
reason = msg.Params[1]
|
||||
}
|
||||
|
||||
if targetNick == c.nick {
|
||||
c.sendNumeric(
|
||||
irc.ErrCantKillServer,
|
||||
"You cannot KILL yourself",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetSID, lookupErr := c.database.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
)
|
||||
if lookupErr != nil {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoSuchNick,
|
||||
targetNick, "No such nick/channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
quitReason := "Killed (" + c.nick + " (" + reason + "))"
|
||||
|
||||
c.svc.BroadcastQuit(
|
||||
ctx, targetSID, targetNick, quitReason,
|
||||
)
|
||||
}
|
||||
|
||||
// handleWallopsCmd broadcasts to all +w users (oper only).
|
||||
func (c *Conn) handleWallopsCmd(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
isOper, err := c.database.IsSessionOper(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err != nil || !isOper {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoPrivileges,
|
||||
"Permission Denied- "+
|
||||
"You're not an IRC operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
"WALLOPS", "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
message := msg.Params[0]
|
||||
|
||||
wallopsSIDs, wallErr := c.database.
|
||||
GetWallopsSessionIDs(ctx)
|
||||
if wallErr != nil {
|
||||
c.log.Error(
|
||||
"get wallops sessions failed",
|
||||
"error", wallErr,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(wallopsSIDs) > 0 {
|
||||
body, mErr := json.Marshal([]string{message})
|
||||
if mErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _, _ = c.svc.FanOut(
|
||||
ctx, irc.CmdWallops, c.nick, "*",
|
||||
nil, body, nil, wallopsSIDs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,24 +11,23 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLineLen = 512
|
||||
readTimeout = 5 * time.Minute
|
||||
writeTimeout = 30 * time.Second
|
||||
dnsTimeout = 3 * time.Second
|
||||
pollInterval = 100 * time.Millisecond
|
||||
pingInterval = 90 * time.Second
|
||||
pongDeadline = 30 * time.Second
|
||||
maxNickLen = 32
|
||||
minPasswordLen = 8
|
||||
maxHashcashBits = 40
|
||||
maxLineLen = 512
|
||||
readTimeout = 5 * time.Minute
|
||||
writeTimeout = 30 * time.Second
|
||||
dnsTimeout = 3 * time.Second
|
||||
pollInterval = 100 * time.Millisecond
|
||||
pingInterval = 90 * time.Second
|
||||
pongDeadline = 30 * time.Second
|
||||
maxNickLen = 32
|
||||
minPasswordLen = 8
|
||||
)
|
||||
|
||||
// cmdHandler is the signature for registered IRC command
|
||||
@@ -130,13 +129,7 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
"CAP": func(_ context.Context, msg *Message) {
|
||||
c.handleCAP(msg)
|
||||
},
|
||||
"USERHOST": c.handleUserhost,
|
||||
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
|
||||
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
|
||||
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
|
||||
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
|
||||
irc.CmdKill: c.handleKillCmd,
|
||||
irc.CmdWallops: c.handleWallopsCmd,
|
||||
"USERHOST": c.handleUserhost,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +434,7 @@ func (c *Conn) deliverWelcome() {
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
"PREFIX=(ov)@+",
|
||||
"CHANMODES=,,H,imnst",
|
||||
"CHANMODES=,,H,mnst",
|
||||
"NETWORK="+c.serverSfx,
|
||||
"are supported by this server",
|
||||
)
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
)
|
||||
|
||||
// NewTestServer creates a Server suitable for testing.
|
||||
@@ -19,9 +19,12 @@ func NewTestServer(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
) *Server {
|
||||
svc := service.NewTestService(
|
||||
database, brk, cfg, log,
|
||||
)
|
||||
svc := &service.Service{
|
||||
DB: database,
|
||||
Broker: brk,
|
||||
Config: cfg,
|
||||
Log: log,
|
||||
}
|
||||
|
||||
return &Server{ //nolint:exhaustruct
|
||||
log: log,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ package ircserver_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||
)
|
||||
|
||||
//nolint:funlen // table-driven test
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// relayMessages polls the client output queue and delivers
|
||||
@@ -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:
|
||||
@@ -307,18 +305,6 @@ func (c *Conn) deliverInviteMsg(
|
||||
c.sendFromServer("NOTICE", c.nick, text)
|
||||
}
|
||||
|
||||
// deliverWallops sends a WALLOPS notification.
|
||||
func (c *Conn) deliverWallops(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
|
||||
c.send(FormatMessage(
|
||||
prefix, irc.CmdWallops, text,
|
||||
))
|
||||
}
|
||||
|
||||
// deliverMode sends a MODE change notification.
|
||||
func (c *Conn) deliverMode(
|
||||
msg *db.IRCMessage,
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating an IRC
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -112,87 +112,6 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestEnvWithOper creates a test environment with oper
|
||||
// credentials configured.
|
||||
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||
t.Name(),
|
||||
)
|
||||
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
_, err = conn.ExecContext(
|
||||
t.Context(), "PRAGMA foreign_keys = ON",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pragma: %v", err)
|
||||
}
|
||||
|
||||
database := db.NewTestDatabaseFromConn(conn)
|
||||
|
||||
err = database.RunMigrations(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
MOTD: "Welcome to test IRC",
|
||||
OperName: "testoper",
|
||||
OperPassword: "testpass",
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
addr := listener.Addr().String()
|
||||
|
||||
err = listener.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||
))
|
||||
|
||||
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||
|
||||
err = srv.Start(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("start irc server: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
srv.Stop()
|
||||
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
t.Logf("close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &testEnv{
|
||||
database: database,
|
||||
brk: brk,
|
||||
cfg: cfg,
|
||||
srv: srv,
|
||||
}
|
||||
}
|
||||
|
||||
// dial connects to the test server.
|
||||
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating a Logger.
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
@@ -15,9 +18,6 @@ import (
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
)
|
||||
|
||||
const corsMaxAge = 300
|
||||
|
||||
@@ -3,7 +3,7 @@ package ratelimit_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||
)
|
||||
|
||||
func TestNewCreatesLimiter(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/web"
|
||||
"git.eeqj.de/sneak/neoirc/web"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/handlers"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/middleware"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -8,15 +8,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating a Service.
|
||||
@@ -31,35 +30,19 @@ type Params struct {
|
||||
|
||||
// Service provides shared business logic for IRC commands.
|
||||
type Service struct {
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
config *config.Config
|
||||
log *slog.Logger
|
||||
DB *db.Database
|
||||
Broker *broker.Broker
|
||||
Config *config.Config
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Service.
|
||||
func New(params Params) *Service {
|
||||
return &Service{
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
log: params.Logger.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestService creates a Service for use in tests
|
||||
// outside the service package.
|
||||
func NewTestService(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
cfg *config.Config,
|
||||
log *slog.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
log: log,
|
||||
DB: params.Database,
|
||||
Broker: params.Broker,
|
||||
Config: params.Config,
|
||||
Log: params.Logger.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +76,7 @@ func (s *Service) FanOut(
|
||||
params, body, meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (int64, string, error) {
|
||||
dbID, msgUUID, err := s.db.InsertMessage(
|
||||
dbID, msgUUID, err := s.DB.InsertMessage(
|
||||
ctx, command, from, to, params, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -101,8 +84,8 @@ func (s *Service) FanOut(
|
||||
}
|
||||
|
||||
for _, sid := range sessionIDs {
|
||||
_ = s.db.EnqueueToSession(ctx, sid, dbID)
|
||||
s.broker.Notify(sid)
|
||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||
s.Broker.Notify(sid)
|
||||
}
|
||||
|
||||
return dbID, msgUUID, nil
|
||||
@@ -137,7 +120,7 @@ func (s *Service) SendChannelMessage(
|
||||
nick, command, channel string,
|
||||
body, meta json.RawMessage,
|
||||
) (int64, string, error) {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
@@ -146,7 +129,7 @@ func (s *Service) SendChannelMessage(
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
@@ -158,7 +141,7 @@ func (s *Service) SendChannelMessage(
|
||||
}
|
||||
|
||||
// Ban check — banned users cannot send messages.
|
||||
isBanned, banErr := s.db.IsSessionBanned(
|
||||
isBanned, banErr := s.DB.IsSessionBanned(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if banErr == nil && isBanned {
|
||||
@@ -169,12 +152,12 @@ func (s *Service) SendChannelMessage(
|
||||
}
|
||||
}
|
||||
|
||||
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||
if moderated {
|
||||
isOp, _ := s.db.IsChannelOperator(
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
isVoiced, _ := s.db.IsChannelVoiced(
|
||||
isVoiced, _ := s.DB.IsChannelVoiced(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
|
||||
@@ -187,7 +170,7 @@ func (s *Service) SendChannelMessage(
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
dbID, uuid, fanErr := s.FanOut(
|
||||
@@ -210,7 +193,7 @@ func (s *Service) SendDirectMessage(
|
||||
nick, command, target string,
|
||||
body, meta json.RawMessage,
|
||||
) (*DirectMsgResult, error) {
|
||||
targetSID, err := s.db.GetSessionByNick(ctx, target)
|
||||
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
||||
if err != nil {
|
||||
return nil, &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
@@ -219,7 +202,7 @@ func (s *Service) SendDirectMessage(
|
||||
}
|
||||
}
|
||||
|
||||
away, _ := s.db.GetAway(ctx, targetSID)
|
||||
away, _ := s.DB.GetAway(ctx, targetSID)
|
||||
|
||||
recipients := []int64{targetSID}
|
||||
if targetSID != sessionID {
|
||||
@@ -245,19 +228,19 @@ func (s *Service) JoinChannel(
|
||||
sessionID int64,
|
||||
nick, channel, suppliedKey string,
|
||||
) (*JoinResult, error) {
|
||||
chID, err := s.db.GetOrCreateChannel(ctx, channel)
|
||||
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||
}
|
||||
|
||||
memberCount, countErr := s.db.CountChannelMembers(
|
||||
memberCount, countErr := s.DB.CountChannelMembers(
|
||||
ctx, chID,
|
||||
)
|
||||
isCreator := countErr == nil && memberCount == 0
|
||||
|
||||
if !isCreator {
|
||||
if joinErr := checkJoinRestrictions(
|
||||
ctx, s.db, chID, sessionID,
|
||||
ctx, s.DB, chID, sessionID,
|
||||
channel, suppliedKey, memberCount,
|
||||
); joinErr != nil {
|
||||
return nil, joinErr
|
||||
@@ -265,11 +248,11 @@ func (s *Service) JoinChannel(
|
||||
}
|
||||
|
||||
if isCreator {
|
||||
err = s.db.JoinChannelAsOperator(
|
||||
err = s.DB.JoinChannelAsOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
} else {
|
||||
err = s.db.JoinChannel(ctx, chID, sessionID)
|
||||
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -277,9 +260,9 @@ func (s *Service) JoinChannel(
|
||||
}
|
||||
|
||||
// Clear invite after successful join.
|
||||
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
|
||||
_ = s.DB.ClearChannelInvite(ctx, chID, sessionID)
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
@@ -301,7 +284,7 @@ func (s *Service) PartChannel(
|
||||
sessionID int64,
|
||||
nick, channel, reason string,
|
||||
) error {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
@@ -310,7 +293,7 @@ func (s *Service) PartChannel(
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
@@ -321,7 +304,7 @@ func (s *Service) PartChannel(
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
@@ -330,8 +313,8 @@ func (s *Service) PartChannel(
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
|
||||
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -343,7 +326,7 @@ func (s *Service) SetTopic(
|
||||
sessionID int64,
|
||||
nick, channel, topic string,
|
||||
) error {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
@@ -352,7 +335,7 @@ func (s *Service) SetTopic(
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
@@ -363,9 +346,9 @@ func (s *Service) SetTopic(
|
||||
}
|
||||
}
|
||||
|
||||
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||
if topicLocked {
|
||||
isOp, _ := s.db.IsChannelOperator(
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isOp {
|
||||
@@ -377,15 +360,15 @@ func (s *Service) SetTopic(
|
||||
}
|
||||
}
|
||||
|
||||
if setErr := s.db.SetTopic(
|
||||
if setErr := s.DB.SetTopic(
|
||||
ctx, channel, topic,
|
||||
); setErr != nil {
|
||||
return fmt.Errorf("set topic: %w", setErr)
|
||||
}
|
||||
|
||||
_ = s.db.SetTopicMeta(ctx, channel, topic, nick)
|
||||
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
@@ -404,7 +387,7 @@ func (s *Service) KickUser(
|
||||
sessionID int64,
|
||||
nick, channel, targetNick, reason string,
|
||||
) error {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
@@ -413,7 +396,7 @@ func (s *Service) KickUser(
|
||||
}
|
||||
}
|
||||
|
||||
isOp, _ := s.db.IsChannelOperator(
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isOp {
|
||||
@@ -424,7 +407,7 @@ func (s *Service) KickUser(
|
||||
}
|
||||
}
|
||||
|
||||
targetSID, err := s.db.GetSessionByNick(
|
||||
targetSID, err := s.DB.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -435,7 +418,7 @@ func (s *Service) KickUser(
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, targetSID,
|
||||
)
|
||||
if !isMember {
|
||||
@@ -446,7 +429,7 @@ func (s *Service) KickUser(
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
params, _ := json.Marshal( //nolint:errchkjson
|
||||
[]string{targetNick},
|
||||
@@ -457,8 +440,8 @@ func (s *Service) KickUser(
|
||||
params, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -470,7 +453,7 @@ func (s *Service) ChangeNick(
|
||||
sessionID int64,
|
||||
oldNick, newNick string,
|
||||
) error {
|
||||
err := s.db.ChangeNick(ctx, sessionID, newNick)
|
||||
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") ||
|
||||
db.IsUniqueConstraintError(err) {
|
||||
@@ -502,7 +485,7 @@ func (s *Service) BroadcastQuit(
|
||||
sessionID int64,
|
||||
nick, reason string,
|
||||
) {
|
||||
channels, err := s.db.GetSessionChannels(
|
||||
channels, err := s.DB.GetSessionChannels(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -512,7 +495,7 @@ func (s *Service) BroadcastQuit(
|
||||
notified := make(map[int64]bool)
|
||||
|
||||
for _, ch := range channels {
|
||||
memberIDs, memErr := s.db.GetChannelMemberIDs(
|
||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||
ctx, ch.ID,
|
||||
)
|
||||
if memErr != nil {
|
||||
@@ -543,11 +526,11 @@ func (s *Service) BroadcastQuit(
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
||||
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
||||
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
// SetAway sets or clears the away message. Returns true
|
||||
@@ -557,7 +540,7 @@ func (s *Service) SetAway(
|
||||
sessionID int64,
|
||||
message string,
|
||||
) (bool, error) {
|
||||
err := s.db.SetAway(ctx, sessionID, message)
|
||||
err := s.DB.SetAway(ctx, sessionID, message)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set away: %w", err)
|
||||
}
|
||||
@@ -572,8 +555,8 @@ func (s *Service) Oper(
|
||||
sessionID int64,
|
||||
name, password string,
|
||||
) error {
|
||||
cfgName := s.config.OperName
|
||||
cfgPassword := s.config.OperPassword
|
||||
cfgName := s.Config.OperName
|
||||
cfgPassword := s.Config.OperPassword
|
||||
|
||||
// Use constant-time comparison and return the same
|
||||
// error for all failures to prevent information
|
||||
@@ -592,7 +575,7 @@ func (s *Service) Oper(
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.db.SetSessionOper(ctx, sessionID, true)
|
||||
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -604,7 +587,7 @@ func (s *Service) ValidateChannelOp(
|
||||
sessionID int64,
|
||||
channel string,
|
||||
) (int64, error) {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return 0, &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
@@ -613,7 +596,7 @@ func (s *Service) ValidateChannelOp(
|
||||
}
|
||||
}
|
||||
|
||||
isOp, _ := s.db.IsChannelOperator(
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isOp {
|
||||
@@ -636,7 +619,7 @@ func (s *Service) ApplyMemberMode(
|
||||
mode rune,
|
||||
adding bool,
|
||||
) error {
|
||||
targetSID, err := s.db.GetSessionByNick(
|
||||
targetSID, err := s.DB.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -647,7 +630,7 @@ func (s *Service) ApplyMemberMode(
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, targetSID,
|
||||
)
|
||||
if !isMember {
|
||||
@@ -660,11 +643,11 @@ func (s *Service) ApplyMemberMode(
|
||||
|
||||
switch mode {
|
||||
case 'o':
|
||||
_ = s.db.SetChannelMemberOperator(
|
||||
_ = s.DB.SetChannelMemberOperator(
|
||||
ctx, chID, targetSID, adding,
|
||||
)
|
||||
case 'v':
|
||||
_ = s.db.SetChannelMemberVoiced(
|
||||
_ = s.DB.SetChannelMemberVoiced(
|
||||
ctx, chID, targetSID, adding,
|
||||
)
|
||||
}
|
||||
@@ -672,8 +655,7 @@ func (s *Service) ApplyMemberMode(
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetChannelFlag applies a simple boolean channel mode
|
||||
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
|
||||
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
||||
func (s *Service) SetChannelFlag(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
@@ -682,37 +664,29 @@ func (s *Service) SetChannelFlag(
|
||||
) error {
|
||||
switch flag {
|
||||
case 'm':
|
||||
if err := s.db.SetChannelModerated(
|
||||
if err := s.DB.SetChannelModerated(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set moderated: %w", err)
|
||||
}
|
||||
case 't':
|
||||
if err := s.db.SetChannelTopicLocked(
|
||||
if err := s.DB.SetChannelTopicLocked(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set topic locked: %w", err)
|
||||
}
|
||||
case 'i':
|
||||
if err := s.db.SetChannelInviteOnly(
|
||||
if err := s.DB.SetChannelInviteOnly(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set invite only: %w", err)
|
||||
}
|
||||
case 's':
|
||||
if err := s.db.SetChannelSecret(
|
||||
if err := s.DB.SetChannelSecret(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set secret: %w", err)
|
||||
}
|
||||
case 'n':
|
||||
if err := s.db.SetChannelNoExternal(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"set no external: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -726,7 +700,7 @@ func (s *Service) BroadcastMode(
|
||||
chID int64,
|
||||
modeText string,
|
||||
) {
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
@@ -735,60 +709,24 @@ func (s *Service) BroadcastMode(
|
||||
)
|
||||
}
|
||||
|
||||
// QueryChannelMode returns the complete channel mode
|
||||
// string including all flags and parameterized modes.
|
||||
// QueryChannelMode returns the channel mode string.
|
||||
func (s *Service) QueryChannelMode(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
) string {
|
||||
modes := "+"
|
||||
|
||||
noExternal, _ := s.db.IsChannelNoExternal(ctx, chID)
|
||||
if noExternal {
|
||||
modes += "n"
|
||||
}
|
||||
|
||||
inviteOnly, _ := s.db.IsChannelInviteOnly(ctx, chID)
|
||||
if inviteOnly {
|
||||
modes += "i"
|
||||
}
|
||||
|
||||
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||
if moderated {
|
||||
modes += "m"
|
||||
}
|
||||
|
||||
secret, _ := s.db.IsChannelSecret(ctx, chID)
|
||||
if secret {
|
||||
modes += "s"
|
||||
}
|
||||
|
||||
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||
if topicLocked {
|
||||
modes += "t"
|
||||
}
|
||||
|
||||
var modeParams string
|
||||
|
||||
key, _ := s.db.GetChannelKey(ctx, chID)
|
||||
if key != "" {
|
||||
modes += "k"
|
||||
modeParams += " " + key
|
||||
}
|
||||
|
||||
limit, _ := s.db.GetChannelUserLimit(ctx, chID)
|
||||
if limit > 0 {
|
||||
modes += "l"
|
||||
modeParams += " " + strconv.Itoa(limit)
|
||||
}
|
||||
|
||||
bits, _ := s.db.GetChannelHashcashBits(ctx, chID)
|
||||
if bits > 0 {
|
||||
modes += "H"
|
||||
modeParams += " " + strconv.Itoa(bits)
|
||||
}
|
||||
|
||||
return modes + modeParams
|
||||
return modes
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
@@ -798,7 +736,7 @@ func (s *Service) broadcastNickChange(
|
||||
sessionID int64,
|
||||
oldNick, newNick string,
|
||||
) {
|
||||
channels, err := s.db.GetSessionChannels(
|
||||
channels, err := s.DB.GetSessionChannels(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -808,7 +746,7 @@ func (s *Service) broadcastNickChange(
|
||||
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
||||
notified := make(map[int64]bool)
|
||||
|
||||
dbID, _, insErr := s.db.InsertMessage(
|
||||
dbID, _, insErr := s.DB.InsertMessage(
|
||||
ctx, irc.CmdNick, oldNick, "",
|
||||
nil, body, nil,
|
||||
)
|
||||
@@ -817,12 +755,12 @@ func (s *Service) broadcastNickChange(
|
||||
}
|
||||
|
||||
// Notify the user themselves (for multi-client sync).
|
||||
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
|
||||
s.broker.Notify(sessionID)
|
||||
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
||||
s.Broker.Notify(sessionID)
|
||||
notified[sessionID] = true
|
||||
|
||||
for _, ch := range channels {
|
||||
memberIDs, memErr := s.db.GetChannelMemberIDs(
|
||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||
ctx, ch.ID,
|
||||
)
|
||||
if memErr != nil {
|
||||
@@ -836,8 +774,8 @@ func (s *Service) broadcastNickChange(
|
||||
|
||||
notified[mid] = true
|
||||
|
||||
_ = s.db.EnqueueToSession(ctx, mid, dbID)
|
||||
s.broker.Notify(mid)
|
||||
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
||||
s.Broker.Notify(mid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,16 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package stats_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/stats"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
|
||||
@@ -2,33 +2,26 @@ 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"
|
||||
CmdMotd = "MOTD"
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdOper = "OPER"
|
||||
CmdPass = "PASS"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
CmdPrivmsg = "PRIVMSG"
|
||||
CmdQuit = "QUIT"
|
||||
CmdTime = "TIME"
|
||||
CmdTopic = "TOPIC"
|
||||
CmdUser = "USER"
|
||||
CmdUserhost = "USERHOST"
|
||||
CmdVersion = "VERSION"
|
||||
CmdWallops = "WALLOPS"
|
||||
CmdWho = "WHO"
|
||||
CmdWhois = "WHOIS"
|
||||
CmdAway = "AWAY"
|
||||
CmdInvite = "INVITE"
|
||||
CmdJoin = "JOIN"
|
||||
CmdKick = "KICK"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
CmdMode = "MODE"
|
||||
CmdMotd = "MOTD"
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdOper = "OPER"
|
||||
CmdPass = "PASS"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
CmdPrivmsg = "PRIVMSG"
|
||||
CmdQuit = "QUIT"
|
||||
CmdTopic = "TOPIC"
|
||||
CmdUser = "USER"
|
||||
CmdWho = "WHO"
|
||||
CmdWhois = "WHOIS"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
||||
"title": "JOIN",
|
||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
||||
"title": "KICK",
|
||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
||||
"title": "MODE",
|
||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
||||
"title": "NICK",
|
||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
||||
"title": "NOTICE",
|
||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
||||
"title": "PART",
|
||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
||||
"title": "PING",
|
||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
||||
"title": "PONG",
|
||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
||||
"title": "PRIVMSG",
|
||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
||||
"title": "PUBKEY",
|
||||
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
||||
"title": "QUIT",
|
||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
||||
"title": "TOPIC",
|
||||
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
||||
"title": "IRC Message Envelope",
|
||||
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
||||
"type": "object",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
||||
"title": "001 RPL_WELCOME",
|
||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
||||
"title": "002 RPL_YOURHOST",
|
||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
||||
"title": "003 RPL_CREATED",
|
||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
||||
"title": "004 RPL_MYINFO",
|
||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
||||
"title": "322 RPL_LIST",
|
||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
||||
"title": "323 RPL_LISTEND",
|
||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
||||
"title": "332 RPL_TOPIC",
|
||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
||||
"title": "353 RPL_NAMREPLY",
|
||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
||||
"title": "366 RPL_ENDOFNAMES",
|
||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
||||
"title": "372 RPL_MOTD",
|
||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
||||
"title": "375 RPL_MOTDSTART",
|
||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
||||
"title": "376 RPL_ENDOFMOTD",
|
||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
||||
"title": "401 ERR_NOSUCHNICK",
|
||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
||||
"title": "403 ERR_NOSUCHCHANNEL",
|
||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
||||
"title": "433 ERR_NICKNAMEINUSE",
|
||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
||||
"title": "442 ERR_NOTONCHANNEL",
|
||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
Reference in New Issue
Block a user