Author SHA1 Message Date
clawbot 260f798af4 feat: add IRC wire protocol listener with shared service layer
check / check (push) Successful in 1m0s
Adds a backward-compatible IRC wire protocol listener (RFC 1459/2812)
with a shared service layer used by both IRC and HTTP transports.

- TCP listener on configurable port (default :6667)
- Full IRC protocol: NICK, USER, JOIN, PART, PRIVMSG, MODE, TOPIC, etc.
- Shared service layer (internal/service/) for consistent code paths
- Tier 2 join restrictions (ban, invite-only, key, limit) in service layer
- Ban check on PRIVMSG in service layer
- SetChannelFlag handles +i and +s modes
- Command dispatch via map[string]cmdHandler pattern
- EXPOSE 6667 in Dockerfile
- Service layer unit tests

closes #89
2026-03-26 17:48:08 -07:00
74 changed files with 403 additions and 5378 deletions
+2 -15
View File
@@ -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
@@ -2324,15 +2324,6 @@ IRC_LISTEN_ADDR=
operator status (`@`).
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
`+v` (voice)
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
`WALLOPS`). `MODE` for any nick other than your own is rejected with
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison
is case-insensitive.
- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel
peers, deletes its session, and then sends the victim a `KILL` and
`ERROR :Closing Link` before closing its socket. This applies to victims on
the IRC listener regardless of whether the `KILL` arrived over IRC or the
HTTP API.
### Bridge to HTTP API
@@ -2829,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
(256259), 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 -1
View File
@@ -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()
+12 -12
View File
@@ -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 (
+1 -1
View File
@@ -1,4 +1,4 @@
module sneak.berlin/go/neoirc
module git.eeqj.de/sneak/neoirc
go 1.24.0
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"strings"
"time"
"sneak.berlin/go/neoirc/pkg/irc"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const (
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"math/big"
"time"
"sneak.berlin/go/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
+2 -2
View File
@@ -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 (
+2 -2
View File
@@ -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
)
+7 -20
View 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(),
)
-16
View File
@@ -58,19 +58,3 @@ func (database *Database) Close() error {
return nil
}
// ExecForTest runs a raw statement against the test
// database. Tests use it to install SQLite triggers that
// force a specific write to fail, so that the atomicity of
// multi-statement helpers can be exercised.
func (database *Database) ExecForTest(
ctx context.Context,
query string,
) error {
_, err := database.conn.ExecContext(ctx, query)
if err != nil {
return fmt.Errorf("exec for test: %w", err)
}
return nil
}
+1 -1
View File
@@ -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) {
+1 -250
View File
@@ -7,14 +7,13 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
@@ -2166,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.
@@ -2414,205 +2367,3 @@ func (database *Database) SetChannelUserLimit(
return nil
}
// SetSessionUserModes applies a set of user-mode flag
// changes to a session inside a single transaction, so a
// multi-mode change such as "+w-o" is all-or-nothing. A nil
// pointer means the caller did not mention that mode and
// the stored value must be left untouched.
func (database *Database) SetSessionUserModes(
ctx context.Context,
sessionID int64,
wallops *bool,
oper *bool,
) error {
if wallops == nil && oper == nil {
return nil
}
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if wallops != nil {
if _, err := transaction.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
boolToInt(*wallops), sessionID,
); err != nil {
_ = transaction.Rollback()
return fmt.Errorf(
"set session wallops: %w", err,
)
}
}
if oper != nil {
if _, err := transaction.ExecContext(
ctx,
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
boolToInt(*oper), sessionID,
); err != nil {
_ = transaction.Rollback()
return fmt.Errorf("set session oper: %w", err)
}
}
if err := transaction.Commit(); err != nil {
_ = transaction.Rollback()
return fmt.Errorf("commit user modes: %w", err)
}
return nil
}
// boolToInt renders a Go bool as the 0/1 integer used for
// boolean columns in the SQLite schema.
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
// SetSessionWallops sets the wallops (+w) flag on a
// session.
func (database *Database) SetSessionWallops(
ctx context.Context,
sessionID int64,
enabled bool,
) error {
val := 0
if enabled {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session wallops: %w", err)
}
return nil
}
// IsSessionWallops returns whether the session has the
// wallops (+w) usermode set.
func (database *Database) IsSessionWallops(
ctx context.Context,
sessionID int64,
) (bool, error) {
var isWallops int
err := database.conn.QueryRowContext(
ctx,
`SELECT is_wallops FROM sessions WHERE id = ?`,
sessionID,
).Scan(&isWallops)
if err != nil {
return false, fmt.Errorf(
"check session wallops: %w", err,
)
}
return isWallops != 0, nil
}
// GetWallopsSessionIDs returns all session IDs that have
// the wallops (+w) usermode set.
func (database *Database) GetWallopsSessionIDs(
ctx context.Context,
) ([]int64, error) {
rows, err := database.conn.QueryContext(
ctx,
`SELECT id FROM sessions WHERE is_wallops = 1`,
)
if err != nil {
return nil, fmt.Errorf(
"get wallops sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var sessionID int64
if scanErr := rows.Scan(&sessionID); scanErr != nil {
return nil, fmt.Errorf(
"scan wallops session: %w", scanErr,
)
}
ids = append(ids, sessionID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate wallops sessions: %w", err,
)
}
return ids, nil
}
// UserhostInfo holds the data needed for RPL_USERHOST.
type UserhostInfo struct {
Nick string
Username string
Hostname string
IsOper bool
AwayMessage string
}
// GetUserhostInfo returns USERHOST info for the given
// nicks. Nicks with no session are omitted from the
// result; any other database failure is returned, because
// an unreadable row is not the same as an absent one.
func (database *Database) GetUserhostInfo(
ctx context.Context,
nicks []string,
) ([]UserhostInfo, error) {
if len(nicks) == 0 {
return nil, nil
}
results := make([]UserhostInfo, 0, len(nicks))
for _, nick := range nicks {
var info UserhostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT nick, username, hostname,
is_oper, away_message
FROM sessions WHERE nick = ?`,
nick,
).Scan(
&info.Nick, &info.Username, &info.Hostname,
&info.IsOper, &info.AwayMessage,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue // nick not online
}
return nil, fmt.Errorf(
"userhost lookup %q: %w", nick, err,
)
}
results = append(results, info)
}
return results, nil
}
+1 -125
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"sneak.berlin/go/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/db"
_ "modernc.org/sqlite"
)
@@ -1488,127 +1488,3 @@ func TestChannelUserLimit(t *testing.T) {
t.Fatalf("expected 0, got %d", limit)
}
}
// TestSetSessionUserModesIsAtomic proves that a multi-mode
// change is all-or-nothing. A trigger makes the is_oper
// UPDATE fail after the is_wallops UPDATE has already run,
// which is exactly the "+w-o" partial-failure the previous
// implementation exhibited: it issued the two UPDATEs
// independently, so +w persisted while the caller reported
// total failure.
func TestSetSessionUserModesIsAtomic(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "alice", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.ExecForTest(ctx,
`CREATE TRIGGER reject_oper
BEFORE UPDATE OF is_oper ON sessions
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
END`,
)
if err != nil {
t.Fatal(err)
}
wallops := true
oper := false
err = database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
)
if err == nil {
t.Fatal("expected the rejected oper write to fail")
}
gotWallops, err := database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if gotWallops {
t.Error(
"wallops persisted despite the transaction " +
"failing; the apply stage is not atomic",
)
}
}
// TestSetSessionUserModesAppliesBoth is the success-path
// counterpart: when nothing fails, both flags are written,
// and a nil pointer leaves that flag untouched.
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "alice", "", "", "",
)
if err != nil {
t.Fatal(err)
}
if err := database.SetSessionOper(
ctx, sessionID, true,
); err != nil {
t.Fatal(err)
}
wallops := true
oper := false
if err := database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
); err != nil {
t.Fatal(err)
}
gotWallops, err := database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
gotOper, err := database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if !gotWallops || gotOper {
t.Errorf(
"want wallops=true oper=false, got %v/%v",
gotWallops, gotOper,
)
}
// A nil pointer must leave the stored value alone.
if err := database.SetSessionUserModes(
ctx, sessionID, nil, nil,
); err != nil {
t.Fatal(err)
}
gotWallops, err = database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if !gotWallops {
t.Error("nil pointers must not clear wallops")
}
}
-6
View File
@@ -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);
-2
View File
@@ -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,
+87 -59
View File
@@ -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
+12 -13
View File
@@ -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) {
@@ -214,7 +214,6 @@ func newTestHandlers(
Config: cfg,
Database: database,
Broker: brk,
Globals: globs,
})
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"sneak.berlin/go/neoirc/pkg/irc"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const minPasswordLength = 8
+10 -10
View File
@@ -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")
-576
View File
@@ -1,576 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
// maxUserhostNicks is the maximum number of nicks allowed
// in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles the USERHOST command.
// Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
// Limit to 5 nicks per RFC 2812.
nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
)
if err != nil {
hdlr.log.Error(
"userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil,
replyStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.svc.ServerVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick,
[]string{version + ".", srvName},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName},
"Administrative info",
)
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
)
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
)
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
for _, line := range hdlr.svc.InfoLines() {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
line,
)
}
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick,
[]string{srvName},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleKill handles the KILL command.
// Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
var targetNick string
if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0])
}
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := "KILLed"
if len(lines) > 1 {
reason = lines[1]
}
targetSID, lookupErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if lookupErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return
}
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
quitReason := "Killed (" + nick + " (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection if it
// holds one.
hdlr.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
message := strings.Join(lines, " ")
wallopsSIDs, err := hdlr.params.Database.
GetWallopsSessionIDs(ctx)
if err != nil {
hdlr.log.Error(
"get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w). Delegates to the
// shared service.ApplyUserMode / service.QueryUserMode so
// that mode string processing is identical for both the
// HTTP API and IRC wire protocol.
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
// Users can only query or change their own modes. The
// check is above the query/change split so that both
// forms are rejected, and uses EqualFold because IRC
// nicks are case-insensitive — matching the wire path
// in ircserver.handleUserMode.
if target != "" && !strings.EqualFold(target, nick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
hdlr.changeUserMode(
writer, request,
sessionID, clientID, nick, lines[0],
)
return
}
// Mode query — delegate to shared service.
modeStr, err := hdlr.svc.QueryUserMode(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"query user mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// changeUserMode applies a mode string to the caller's own
// session. The caller has already verified that the target
// nick is the caller's own.
func (hdlr *Handlers) changeUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
newModes, err := hdlr.svc.ApplyUserMode(
ctx, sessionID, modeStr,
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return
}
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"testing"
"sneak.berlin/go/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"sneak.berlin/go/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const testBits = 2
+5 -5
View File
@@ -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.
+34 -380
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"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"
)
// sendIRCError maps a service.IRCError to an IRC numeric
@@ -349,10 +349,7 @@ func (c *Conn) handleQuit(msg *Message) {
c.send("ERROR :Closing Link: " + c.hostname +
" (Quit: " + reason + ")")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
}
// handleTopic gets or sets a channel topic via the shared
@@ -434,7 +431,7 @@ func (c *Conn) handleMode(
if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg)
} else {
c.handleUserMode(ctx, msg)
c.handleUserMode(msg)
}
}
@@ -493,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(
@@ -626,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
}
}
@@ -697,13 +571,10 @@ 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.currentNick()) {
if !strings.EqualFold(target, c.nick) {
c.sendNumeric(
irc.ErrUsersDoNotMatch,
"Can't change mode for other users",
@@ -712,48 +583,8 @@ func (c *Conn) handleUserMode(
return
}
// Mode query (no mode string).
if len(msg.Params) < 2 { //nolint:mnd
modes, err := c.svc.QueryUserMode(
ctx, c.sessionID,
)
if err != nil {
c.log.Error(
"query user mode failed", "error", err,
)
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unable to read user modes",
)
return
}
c.sendNumeric(irc.RplUmodeIs, modes)
return
}
newModes, err := c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
c.sendNumeric(ircErr.Code, ircErr.Message)
return
}
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
c.sendNumeric(irc.RplUmodeIs, newModes)
// We don't support user modes beyond the basics.
c.sendNumeric(irc.RplUmodeIs, "+")
}
// handleNames replies with channel member list.
@@ -1345,180 +1176,3 @@ func (c *Conn) handleUserhost(
strings.Join(replies, " "),
)
}
// handleVersion replies with the server version string.
func (c *Conn) handleVersion() {
c.sendNumeric(
irc.RplVersion,
c.svc.ServerVersion()+".", c.serverSfx,
"",
)
}
// handleAdmin replies with server admin info.
func (c *Conn) handleAdmin() {
srvName := c.serverSfx
c.sendNumeric(
irc.RplAdminMe,
srvName, "Administrative info",
)
c.sendNumeric(
irc.RplAdminLoc1,
"neoirc server",
)
c.sendNumeric(
irc.RplAdminLoc2,
"IRC over HTTP",
)
c.sendNumeric(
irc.RplAdminEmail,
"admin@"+srvName,
)
}
// handleInfo replies with server software info.
func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line)
}
c.sendNumeric(
irc.RplEndOfInfo,
"End of /INFO list",
)
}
// handleTime replies with the server's current time.
func (c *Conn) handleTime() {
srvName := c.serverSfx
c.sendNumeric(
irc.RplTime,
srvName, time.Now().Format(time.RFC1123),
)
}
// handleKillCmd forcibly disconnects a target user (oper
// only).
func (c *Conn) handleKillCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"KILL", "Not enough parameters",
)
return
}
targetNick := msg.Params[0]
reason := "KILLed"
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
killerNick := c.currentNick()
if strings.EqualFold(targetNick, killerNick) {
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
)
return
}
targetSID, lookupErr := c.database.GetSessionByNick(
ctx, targetNick,
)
if lookupErr != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
quitReason := "Killed (" + killerNick +
" (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection.
c.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
}
// handleWallopsCmd broadcasts to all +w users (oper only).
func (c *Conn) handleWallopsCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"WALLOPS", "Not enough parameters",
)
return
}
message := msg.Params[0]
wallopsSIDs, wallErr := c.database.
GetWallopsSessionIDs(ctx)
if wallErr != nil {
c.log.Error(
"get wallops sessions failed",
"error", wallErr,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
return
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.currentNick(), "*",
nil, body, nil, wallopsSIDs,
)
}
}
+21 -154
View File
@@ -11,25 +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
killWriteWindow = 2 * 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
@@ -47,12 +45,6 @@ type Conn struct {
serverSfx string
commands map[string]cmdHandler
// writeMu serializes writes to conn. A connection is
// written to by its own read loop, by its relay
// goroutine, and — when an operator KILLs it — by
// another client's goroutine.
writeMu sync.Mutex
mu sync.Mutex
nick string
username string
@@ -69,7 +61,6 @@ type Conn struct {
lastQueueID int64
closed bool
killed bool
cancel context.CancelFunc
}
@@ -106,76 +97,6 @@ func newConn(
return conn
}
// Disconnect terminates the connection on behalf of an
// operator KILL issued from either transport. The victim
// is told why, then its socket is closed so that the read
// loop unblocks and serve() returns; without the close the
// victim would keep a socket that looks alive but silently
// delivers nothing. Disconnect is called from the killer's
// goroutine, never the victim's.
//
// The notify-and-close half runs on its own goroutine and
// under a short deadline. There is no per-client send
// queue: send() writes straight to the victim's socket, so
// a victim that has stopped reading would otherwise stall
// the killer for the full writeTimeout on each of the two
// writes -- wedging the operator's own serve() loop on the
// IRC path, or the API request on the HTTP path. Any
// client could trigger that deliberately. Disconnect
// therefore returns as soon as the victim is marked closed;
// the socket is closed shortly afterwards regardless of
// whether the notification could be delivered.
func (c *Conn) Disconnect(reason string) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
c.killed = true
nick := c.nick
host := c.hostname
c.mu.Unlock()
if nick == "" {
nick = "*"
}
// Stop the relay goroutine, which would otherwise keep
// polling a queue belonging to a deleted session.
if c.cancel != nil {
c.cancel()
}
go c.notifyKilledAndClose(nick, host, reason)
}
// notifyKilledAndClose delivers the KILL and ERROR lines to
// a killed victim and then closes its socket. It runs on a
// goroutine owned by neither the killer nor the victim, and
// bounds both writes with killWriteWindow so an unresponsive
// victim cannot hold the socket open indefinitely.
func (c *Conn) notifyKilledAndClose(
nick, host, reason string,
) {
defer c.conn.Close() //nolint:errcheck
c.sendWithin(
killWriteWindow,
FormatMessage(
c.serverSfx, irc.CmdKill, nick, reason,
),
)
c.sendWithin(
killWriteWindow,
"ERROR :Closing Link: "+host+
" ("+reason+")",
)
}
// buildCommandMap returns a map from IRC command strings
// to handler functions.
func (c *Conn) buildCommandMap() map[string]cmdHandler {
@@ -208,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(context.Context, *Message) { c.handleVersion() },
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
irc.CmdKill: c.handleKillCmd,
irc.CmdWallops: c.handleWallopsCmd,
"USERHOST": c.handleUserhost,
}
}
@@ -263,7 +178,7 @@ func (c *Conn) serve(ctx context.Context) {
c.handleMessage(ctx, msg)
if c.isClosed() {
if c.closed {
return
}
}
@@ -272,65 +187,24 @@ func (c *Conn) serve(ctx context.Context) {
func (c *Conn) cleanup(ctx context.Context) {
c.mu.Lock()
wasRegistered := c.registered
wasKilled := c.killed
sessID := c.sessionID
nick := c.nick
c.closed = true
c.mu.Unlock()
if wasRegistered && sessID > 0 {
c.svc.UnregisterWireConn(sessID, c)
// A KILLed session has already been broadcast and
// deleted by the killer; broadcasting again would
// fan out a QUIT for a session row that is gone.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.conn.Close() //nolint:errcheck,gosec
}
// isClosed reports whether the connection has been marked
// for teardown, either by QUIT or by an operator KILL.
func (c *Conn) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
// currentNick returns the connection's registered nick.
// c.nick is written under c.mu during registration and
// NICK changes, so every read must take the mutex.
func (c *Conn) currentNick() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.nick
}
// send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line)
}
// sendWithin writes a formatted IRC line to the connection
// under the given write deadline. Callers that must not be
// held hostage by an unresponsive peer pass a shorter window
// than writeTimeout.
func (c *Conn) sendWithin(
timeout time.Duration,
line string,
) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline(
time.Now().Add(timeout),
time.Now().Add(writeTimeout),
)
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
@@ -511,10 +385,7 @@ func (c *Conn) completeRegistration(ctx context.Context) {
"failed to create session", "error", err,
)
c.send("ERROR :Internal server error")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return
}
@@ -525,10 +396,6 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true
c.mu.Unlock()
// Make this connection reachable by session ID so that
// KILL from either transport can disconnect it.
c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
@@ -567,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",
)
-128
View File
@@ -1,128 +0,0 @@
package ircserver_test
import (
"bufio"
"log/slog"
"net"
"os"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/ircserver"
)
// disconnectBudget is how long Disconnect is allowed to
// take when the victim never reads. It is far below the
// 30s writeTimeout that the old synchronous implementation
// would have burned on each of its two writes.
const disconnectBudget = 2 * time.Second
// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves
// that an operator KILL cannot be stalled by its target.
// The victim's socket is a net.Pipe, so every write blocks
// until the peer reads and the peer here never does. The
// old implementation performed both notification writes on
// the killer's goroutine, which wedged the operator's own
// serve() loop on the IRC path and the API request on the
// HTTP path for as long as the victim cared to stay silent.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T,
) {
t.Parallel()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
returned := make(chan struct{})
go func() {
victim.Disconnect("killed by oper")
close(returned)
}()
select {
case <-returned:
case <-time.After(disconnectBudget):
t.Fatal(
"Disconnect blocked on the victim's socket; " +
"the killer must not be held hostage",
)
}
}
// TestDisconnectNotifiesAndClosesVictim is the other half
// of the contract: moving the notification off the killer's
// goroutine must not lose it. A victim that does read gets
// both the KILL and the ERROR line, and then its socket is
// closed so its read loop unblocks.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
lines := make(chan []string, 1)
go func() {
var got []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
got = append(got, scanner.Text())
}
lines <- got
}()
victim.Disconnect("killed by oper")
var got []string
select {
case got = <-lines:
case <-time.After(5 * time.Second):
t.Fatal("victim socket was never closed")
}
joined := strings.Join(got, "\n")
if !strings.Contains(joined, "KILL victim") {
t.Errorf("missing KILL line, got: %q", joined)
}
if !strings.Contains(joined, "ERROR :Closing Link:") {
t.Errorf("missing ERROR line, got: %q", joined)
}
}
+9 -32
View File
@@ -4,13 +4,11 @@ import (
"context"
"log/slog"
"net"
"time"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service"
"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.
@@ -21,16 +19,13 @@ func NewTestServer(
database *db.Database,
brk *broker.Broker,
) *Server {
globs := &globals.Globals{
Appname: "neoirc",
Version: "test",
StartTime: time.Now(),
svc := &service.Service{
DB: database,
Broker: brk,
Config: cfg,
Log: log,
}
svc := service.NewTestService(
database, brk, cfg, globs, log,
)
return &Server{ //nolint:exhaustruct
log: log,
cfg: cfg,
@@ -55,21 +50,3 @@ func (s *Server) Stop() {
func (s *Server) Listener() net.Listener {
return s.listener
}
// NewTestConn wraps an already-established net.Conn in a
// Conn so tests can drive connection-level behaviour such
// as Disconnect without standing up a whole server.
func NewTestConn(
log *slog.Logger,
cfg *config.Config,
tcpConn net.Conn,
nick string,
) *Conn {
conn := newConn(
context.Background(), tcpConn, log,
nil, nil, cfg, nil,
)
conn.nick = nick
return conn
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+2 -16
View File
@@ -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,
+5 -5
View File
@@ -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
+5 -151
View File
@@ -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"
)
@@ -38,20 +38,6 @@ type testEnv struct {
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithServerName(t, "test.irc")
}
// newTestEnvWithServerName creates a test environment with
// an explicit SERVER_NAME. Passing "" exercises the shipped
// default from config.go, under which the server must fall
// back to "neoirc" rather than emitting an empty
// server-name parameter.
func newTestEnvWithServerName(
t *testing.T,
serverName string,
) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
@@ -81,7 +67,7 @@ func newTestEnvWithServerName(
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: serverName,
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
}
@@ -126,87 +112,6 @@ func newTestEnvWithServerName(
}
}
// 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()
@@ -305,36 +210,6 @@ func (tc *testClient) register(nick string) []string {
})
}
// readUntilClosed reads until the peer closes the
// connection, returning the lines received first. It
// fails the test if the connection is still open when the
// read deadline expires, which is what a KILL that never
// terminates the victim's socket looks like.
func (tc *testClient) readUntilClosed() []string {
tc.t.Helper()
_ = tc.conn.SetReadDeadline(
time.Now().Add(testTimeout),
)
var lines []string
for tc.scanner.Scan() {
lines = append(lines, tc.scanner.Text())
}
err := tc.scanner.Err()
if err != nil {
tc.t.Fatalf(
"expected EOF on victim socket, got %v "+
"(lines: %v)",
err, lines,
)
}
return lines
}
// assertContains checks that at least one line matches the
// given substring.
func assertContains(
@@ -353,27 +228,6 @@ func assertContains(
t.Errorf("did not find %q in output: %s", substr, description)
}
// assertNotContains checks that no line matches the given
// substring.
func assertNotContains(
t *testing.T,
lines []string,
substr, description string,
) {
t.Helper()
for _, line := range lines {
if strings.Contains(line, substr) {
t.Errorf(
"unexpectedly found %q in output: %s",
substr, description,
)
return
}
}
}
// joinAndDrain joins a channel and reads until
// RPL_ENDOFNAMES.
func (tc *testClient) joinAndDrain(channel string) {
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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"
+21 -17
View File
@@ -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"
@@ -45,6 +45,7 @@ type Params struct {
// It manages routing, middleware, and lifecycle.
type Server struct {
startupTime time.Time
exitCode int
sentryEnabled bool
log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
@@ -70,17 +71,7 @@ func New(
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
srv.startupTime = time.Now()
// Configure, enable Sentry, and build the router
// synchronously so that srv.router is fully initialized
// before OnStart returns. Any HTTP traffic (including
// httptest harnesses that wrap srv as a handler) is
// therefore guaranteed to see an initialized router,
// eliminating the previous race between SetupRoutes
// and ServeHTTP.
srv.configure()
srv.enableSentry()
srv.SetupRoutes()
go srv.serve() //nolint:contextcheck
go srv.Run() //nolint:contextcheck
return nil
},
@@ -92,6 +83,13 @@ func New(
return srv, nil
}
// Run starts the server configuration, Sentry, and begins serving.
func (srv *Server) Run() {
srv.configure()
srv.enableSentry()
srv.serve()
}
// ServeHTTP delegates to the chi router.
func (srv *Server) ServeHTTP(
writer http.ResponseWriter,
@@ -129,7 +127,7 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true
}
func (srv *Server) serve() {
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
@@ -154,6 +152,8 @@ func (srv *Server) serve() {
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() {
@@ -161,6 +161,8 @@ func (srv *Server) cleanupForExit() {
}
func (srv *Server) cleanShutdown() {
srv.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout,
)
@@ -200,6 +202,8 @@ func (srv *Server) serveUntilShutdown() {
Handler: srv,
}
srv.SetupRoutes()
srv.log.Info(
"http begin listen", "listenaddr", listenAddr,
)
+85 -455
View File
@@ -8,18 +8,14 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"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/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/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
// Params defines the dependencies for creating a Service.
@@ -30,149 +26,23 @@ type Params struct {
Config *config.Config
Database *db.Database
Broker *broker.Broker
Globals *globals.Globals
}
// WireConn is a live client connection that a transport
// registers with the service so that commands such as KILL
// can reach it. The IRC wire server registers one per
// registered connection; the HTTP transport has no
// long-lived socket and registers nothing.
type WireConn interface {
// Disconnect terminates the connection, telling the
// client why before closing the socket.
Disconnect(reason string)
}
// Service provides shared business logic for IRC commands.
type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
globals *globals.Globals
log *slog.Logger
wireMu sync.Mutex
wireConns map[int64]WireConn
DB *db.Database
Broker *broker.Broker
Config *config.Config
Log *slog.Logger
}
// New creates a new Service.
func New(params Params) *Service {
return &Service{ //nolint:exhaustruct // mutex zero value
db: params.Database,
broker: params.Broker,
config: params.Config,
globals: params.Globals,
log: params.Logger.Get(),
wireConns: make(map[int64]WireConn),
}
}
// NewTestService creates a Service for use in tests
// outside the service package.
func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
globs *globals.Globals,
log *slog.Logger,
) *Service {
return &Service{ //nolint:exhaustruct // mutex zero value
db: database,
broker: brk,
config: cfg,
globals: globs,
log: log,
wireConns: make(map[int64]WireConn),
}
}
// ServerVersion returns the canonical server version string
// used by every transport, e.g. "neoirc-1.2.3". Both the
// IRC wire protocol and the HTTP API must report the same
// string, so this is the only place it is built.
func (s *Service) ServerVersion() string {
name := "neoirc"
ver := "dev"
if s.globals != nil {
if s.globals.Appname != "" {
name = s.globals.Appname
}
if s.globals.Version != "" {
ver = s.globals.Version
}
}
return name + "-" + ver
}
// InfoLines returns the RPL_INFO body. Both transports
// send exactly these lines so that INFO does not diverge
// between the wire protocol and the HTTP API.
func (s *Service) InfoLines() []string {
started := "unknown"
if s.globals != nil && !s.globals.StartTime.IsZero() {
started = s.globals.StartTime.Format(time.RFC1123)
}
return []string{
"neoirc — IRC semantics over HTTP",
"Version: " + s.ServerVersion(),
"Written in Go",
"Started: " + started,
}
}
// RegisterWireConn associates a live wire connection with
// its session ID so that KillSession can reach it.
func (s *Service) RegisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
s.wireConns[sessionID] = conn
}
// UnregisterWireConn removes the association created by
// RegisterWireConn. It is a no-op if the session has
// already been rebound to a different connection.
func (s *Service) UnregisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
if s.wireConns[sessionID] == conn {
delete(s.wireConns, sessionID)
}
}
// KillSession forcibly removes a user from the server: the
// victim's channel peers are told via QUIT, the victim's
// session is deleted, and any live wire connection it holds
// is disconnected. Both the IRC KILL command and the HTTP
// KILL endpoint route through here so the two transports
// cannot diverge.
func (s *Service) KillSession(
ctx context.Context,
sessionID int64,
nick, reason string,
) {
s.BroadcastQuit(ctx, sessionID, nick, reason)
// A session with no registered wire connection (an
// HTTP-only client) has nothing left to disconnect.
s.wireMu.Lock()
conn := s.wireConns[sessionID]
s.wireMu.Unlock()
if conn != nil {
conn.Disconnect(reason)
return &Service{
DB: params.Database,
Broker: params.Broker,
Config: params.Config,
Log: params.Logger.Get(),
}
}
@@ -206,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 {
@@ -214,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
@@ -250,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,
@@ -259,7 +129,7 @@ func (s *Service) SendChannelMessage(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -271,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 {
@@ -282,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,
)
@@ -300,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(
@@ -323,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,
@@ -332,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 {
@@ -358,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
@@ -378,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 {
@@ -390,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
@@ -414,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,
@@ -423,7 +293,7 @@ func (s *Service) PartChannel(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -434,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
@@ -443,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
}
@@ -456,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,
@@ -465,7 +335,7 @@ func (s *Service) SetTopic(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -476,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 {
@@ -490,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
@@ -517,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,
@@ -526,7 +396,7 @@ func (s *Service) KickUser(
}
}
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -537,7 +407,7 @@ func (s *Service) KickUser(
}
}
targetSID, err := s.db.GetSessionByNick(
targetSID, err := s.DB.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -548,7 +418,7 @@ func (s *Service) KickUser(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -559,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},
@@ -570,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
}
@@ -583,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) {
@@ -615,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 {
@@ -625,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 {
@@ -656,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
@@ -670,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)
}
@@ -685,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
@@ -705,7 +575,7 @@ func (s *Service) Oper(
}
}
_ = s.db.SetSessionOper(ctx, sessionID, true)
_ = s.DB.SetSessionOper(ctx, sessionID, true)
return nil
}
@@ -717,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,
@@ -726,7 +596,7 @@ func (s *Service) ValidateChannelOp(
}
}
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -749,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 {
@@ -760,7 +630,7 @@ func (s *Service) ApplyMemberMode(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -773,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,
)
}
@@ -785,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,
@@ -795,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
@@ -839,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
@@ -848,255 +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
}
// QueryUserMode returns the current user mode string for
// the given session (e.g. "+ow", "+w", "+"). A database
// failure is returned rather than being reported as an
// unset flag: an unreadable mode is not the same as an
// absent one, and reporting "+" for either would tell the
// user they are de-opered when the truth is unknown.
func (s *Service) QueryUserMode(
ctx context.Context,
sessionID int64,
) (string, error) {
modes := "+"
isOper, err := s.db.IsSessionOper(ctx, sessionID)
if err != nil {
return "", fmt.Errorf(
"query oper flag: %w", err,
)
}
if isOper {
modes += "o"
}
isWallops, err := s.db.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
return "", fmt.Errorf(
"query wallops flag: %w", err,
)
}
if isWallops {
modes += "w"
}
return modes, nil
}
// userModeOp is a single parsed user-mode change collected
// by parseUserModeString before any DB writes happen.
type userModeOp struct {
char rune
adding bool
}
// ApplyUserMode parses an IRC user-mode string and applies
// the resulting changes atomically. It supports multiple
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
// rejects malformed input (empty string, no leading sign,
// bare sign with no mode letters, unknown mode letters,
// +o which must be set via OPER) with an IRCError. On
// failure, no persistent change is made: parsing happens
// before any write, and the writes themselves run inside a
// single database transaction that is rolled back whole if
// any statement fails. On success, the resulting mode
// string is returned.
func (s *Service) ApplyUserMode(
ctx context.Context,
sessionID int64,
modeStr string,
) (string, error) {
ops, err := parseUserModeString(modeStr)
if err != nil {
return "", err
}
wallops, oper, err := collapseUserModeOps(ops)
if err != nil {
return "", err
}
if err := s.db.SetSessionUserModes(
ctx, sessionID, wallops, oper,
); err != nil {
s.log.Error(
"apply user modes failed", "error", err,
)
return "", fmt.Errorf("apply user modes: %w", err)
}
return s.QueryUserMode(ctx, sessionID)
}
// parseUserModeString validates and parses a user-mode
// string into a list of operations. The string must begin
// with '+' or '-'; subsequent '+' / '-' characters flip the
// active sign, and letters between them are applied with
// the current sign. Every letter must be a recognized user
// mode for this server, and '+o' is never allowed via MODE
// (use OPER to become operator). If any character is
// invalid, no operations are returned and an IRCError with
// ERR_UMODEUNKNOWNFLAG (501) is returned.
func parseUserModeString(
modeStr string,
) ([]userModeOp, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
if modeStr == "" {
return nil, unknownFlag
}
first := modeStr[0]
if first != '+' && first != '-' {
return nil, unknownFlag
}
ops := make([]userModeOp, 0, len(modeStr)-1)
adding := true
for _, modeChar := range modeStr {
switch modeChar {
case '+':
adding = true
case '-':
adding = false
default:
if !isKnownUserModeChar(modeChar) {
return nil, unknownFlag
}
if modeChar == 'o' && adding {
return nil, unknownFlag
}
ops = append(ops, userModeOp{
char: modeChar, adding: adding,
})
}
}
if len(ops) == 0 {
return nil, unknownFlag
}
return ops, nil
}
// isKnownUserModeChar reports whether the character is a
// recognized user mode letter.
func isKnownUserModeChar(modeChar rune) bool {
switch modeChar {
case 'w', 'o':
return true
default:
return false
}
}
// collapseUserModeOps reduces an already-parsed operation
// list to the final desired value of each user mode flag.
// A nil result for a flag means the mode string never
// mentioned it, so it must be left untouched. Later
// operations win over earlier ones for the same letter
// (e.g. "+w-w" ends with wallops off), which matches the
// left-to-right semantics of applying each op in turn.
// parseUserModeString must have validated every character
// and sign before this runs; the default branch here is
// defence-in-depth only.
func collapseUserModeOps(
ops []userModeOp,
) (*bool, *bool, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
var wallops, oper *bool
for _, modeOp := range ops {
switch modeOp.char {
case 'w':
val := modeOp.adding
wallops = &val
case 'o':
if modeOp.adding {
return nil, nil, unknownFlag
}
val := false
oper = &val
default:
return nil, nil, unknownFlag
}
}
return wallops, oper, nil
return modes
}
// broadcastNickChange notifies channel peers of a nick
@@ -1106,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 {
@@ -1116,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,
)
@@ -1125,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 {
@@ -1144,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)
}
}
}
+10 -459
View File
@@ -11,18 +11,17 @@ import (
"fmt"
"os"
"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/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) {
@@ -56,10 +55,9 @@ func newTestEnv(t *testing.T) *testEnv {
app := fxtest.New(t,
fx.Provide(
func() *globals.Globals {
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
return &globals.Globals{ //nolint:exhaustruct
Appname: "neoirc-test",
Version: "test",
}
},
logger.New,
@@ -365,450 +363,3 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
t.Errorf("operator should be able to send in moderated channel: %v", err)
}
}
func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Fresh session has no modes.
modes, err := env.svc.QueryUserMode(ctx, sid)
if err != nil {
t.Fatalf("query user mode: %v", err)
}
if modes != "+" {
t.Errorf("expected +, got %s", modes)
}
// Set wallops.
_ = env.db.SetSessionWallops(ctx, sid, true)
modes, err = env.svc.QueryUserMode(ctx, sid)
if err != nil {
t.Fatalf("query user mode: %v", err)
}
if modes != "+w" {
t.Errorf("expected +w, got %s", modes)
}
// Set oper.
_ = env.db.SetSessionOper(ctx, sid, true)
modes, err = env.svc.QueryUserMode(ctx, sid)
if err != nil {
t.Fatalf("query user mode: %v", err)
}
if modes != "+ow" {
t.Errorf("expected +ow, got %s", modes)
}
}
// applyUserModeCaseState is the subset of session user-mode
// state the rigorous TestApplyUserMode suite asserts on. It
// mirrors the columns (oper, wallops) that the parser is
// permitted to mutate.
type applyUserModeCaseState struct {
oper bool
wallops bool
}
// applyUserModeCase describes one rigor-suite case for
// Service.ApplyUserMode: the pre-call DB state, the mode
// string input, and the expected post-call observable state
// (mode string on success, IRC error code on rejection, and
// persisted session state either way).
type applyUserModeCase struct {
name string
initialState applyUserModeCaseState
modeStr string
wantModes string
wantErr bool
wantErrCode irc.IRCMessageType
wantState applyUserModeCaseState
}
// applyUserModeCases returns every case listed in sneak's
// review of PR #96 plus a few adjacent ones. Split across
// helpers by category so each stays under funlen.
func applyUserModeCases() []applyUserModeCase {
cases := applyUserModeHappyPathCases()
cases = append(cases, applyUserModeSignTransitionCases()...)
cases = append(cases, applyUserModeMalformedCases()...)
cases = append(cases, applyUserModeUnknownLetterCases()...)
return cases
}
// applyUserModeHappyPathCases covers valid single-char and
// multi-char-without-sign-transition mode operations.
func applyUserModeHappyPathCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w from empty",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+w",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "-w from +w",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "-w",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "-o",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-wo from +ow",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-wo",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeSignTransitionCases covers multi-char mode
// strings where '+' and '-' flip partway through. +o is
// never legal via MODE, so strings containing it must be
// rejected atomically.
func applyUserModeSignTransitionCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "+w-o",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
// +o is rejected before any op applies; wallops
// stays set.
name: "-w+o always rejects +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-w+o",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
{
// Wallops must NOT be cleared; oper must NOT be
// cleared. Rejection is fully atomic.
name: "+o-w+w rejects because of +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "+o-w+w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
}
}
// applyUserModeMalformedCases covers inputs that lack a
// leading '+' or '-' and inputs that consist of bare signs
// without mode letters. All must be rejected with no side
// effects.
func applyUserModeMalformedCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "w no prefix rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Prove wallops is NOT cleared — the whole point
// of sneak's review.
name: "xw no prefix rejects (would have been" +
" silently -w before)",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "xw",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "empty string rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare + rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare - rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+-+ rejects (no mode letters)",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+-+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeUnknownLetterCases covers well-formed prefix
// strings that contain unknown mode letters. Rejection must
// be atomic: any valid letters before the invalid one must
// not persist.
func applyUserModeUnknownLetterCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "-x+y rejects unknown -x",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-x+y",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+y-x rejects unknown +y",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+y-x",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+z unknown mode rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+z",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Wallops must NOT be set.
name: "+wz rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wz",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+wo rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wo",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// TestApplyUserMode is the rigorous table-driven suite for
// the shared user-mode parser. It covers every case listed
// in sneak's review of PR #96 plus a few adjacent ones.
// Each case asserts the resulting mode string AND the
// persisted session state, to prove that rejected input
// leaves no side effects.
func TestApplyUserMode(t *testing.T) {
for _, testCase := range applyUserModeCases() {
t.Run(testCase.name, func(t *testing.T) {
runApplyUserModeCase(t, testCase)
})
}
}
// runApplyUserModeCase executes one applyUserModeCase: seed
// the session state, invoke ApplyUserMode, and verify both
// the returned value and the post-call persisted state.
func runApplyUserModeCase(
t *testing.T, testCase applyUserModeCase,
) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
result, err := env.svc.ApplyUserMode(
ctx, sid, testCase.modeStr,
)
verifyApplyUserModeOutcome(t, testCase, result, err)
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
}
// seedApplyUserModeState installs the pre-call session
// state described by initialState.
func seedApplyUserModeState(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
initialState applyUserModeCaseState,
) {
t.Helper()
if initialState.oper {
if err := database.SetSessionOper(
ctx, sid, true,
); err != nil {
t.Fatalf("init oper: %v", err)
}
}
if initialState.wallops {
if err := database.SetSessionWallops(
ctx, sid, true,
); err != nil {
t.Fatalf("init wallops: %v", err)
}
}
}
// verifyApplyUserModeOutcome asserts the direct return
// value of ApplyUserMode. It dispatches to the error- or
// success-specific verifier based on wantErr.
func verifyApplyUserModeOutcome(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if testCase.wantErr {
verifyApplyUserModeError(t, testCase, err)
return
}
verifyApplyUserModeSuccess(t, testCase, result, err)
}
// verifyApplyUserModeError checks that err is a
// *service.IRCError whose code matches wantErrCode.
func verifyApplyUserModeError(
t *testing.T, testCase applyUserModeCase, err error,
) {
t.Helper()
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError, got %v", err)
}
if ircErr.Code != testCase.wantErrCode {
t.Errorf(
"code: want %d got %d",
testCase.wantErrCode, ircErr.Code,
)
}
}
// verifyApplyUserModeSuccess checks that err is nil and the
// returned mode string matches wantModes.
func verifyApplyUserModeSuccess(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != testCase.wantModes {
t.Errorf(
"modes: want %q got %q",
testCase.wantModes, result,
)
}
}
// verifyApplyUserModeState asserts the post-call persisted
// session state. This is the atomicity guarantee sneak
// demanded: whether the call succeeded or was rejected, the
// DB must match wantState exactly.
func verifyApplyUserModeState(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
wantState applyUserModeCaseState,
) {
t.Helper()
gotOper, err := database.IsSessionOper(ctx, sid)
if err != nil {
t.Fatalf("read oper: %v", err)
}
gotWallops, err := database.IsSessionWallops(ctx, sid)
if err != nil {
t.Fatalf("read wallops: %v", err)
}
if gotOper != wantState.oper {
t.Errorf(
"oper: want %v got %v",
wantState.oper, gotOper,
)
}
if gotWallops != wantState.wallops {
t.Errorf(
"wallops: want %v got %v",
wantState.wallops, gotWallops,
)
}
}
+1 -1
View File
@@ -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) {
+22 -29
View File
@@ -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"
)
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",