1 Commits

Author SHA1 Message Date
92d5145ac6 feat: add IRC wire protocol listener with shared service layer
Some checks failed
check / check (push) Failing after 46s
Add a traditional IRC wire protocol listener (RFC 1459/2812) on
configurable port (default :6667), sharing business logic with
the HTTP API via a new service layer.

- IRC listener: NICK, USER, PASS, JOIN, PART, PRIVMSG, NOTICE,
  TOPIC, MODE, KICK, QUIT, NAMES, LIST, WHOIS, WHO, AWAY, OPER,
  INVITE, LUSERS, MOTD, PING/PONG, CAP
- Service layer: shared logic for both transports including
  channel join (with Tier 2 checks: ban/invite/key/limit),
  message send (with ban + moderation checks), nick change,
  topic, kick, mode, quit broadcast, away, oper, invite
- BroadcastQuit uses FanOut pattern (one insert, N enqueues)
- HTTP handlers delegate to service for all command logic
- Tier 2 mode operations (+b/+i/+s/+k/+l) use service methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:01:36 -07:00
68 changed files with 404 additions and 1604 deletions

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()

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 (

2
go.mod
View File

@@ -1,4 +1,4 @@
module sneak.berlin/go/neoirc
module git.eeqj.de/sneak/neoirc
go 1.24.0

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) {

View File

@@ -15,7 +15,7 @@ import (
"strings"
"time"
"sneak.berlin/go/neoirc/pkg/irc"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const (

View File

@@ -8,7 +8,7 @@ import (
"math/big"
"time"
"sneak.berlin/go/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (

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 (

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
)

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(),
)

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) {

View File

@@ -12,8 +12,8 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
@@ -2165,52 +2165,6 @@ func (database *Database) SetChannelSecret(
return nil
}
// --- No External Messages (+n) ---
// IsChannelNoExternal checks if a channel has +n mode.
func (database *Database) IsChannelNoExternal(
ctx context.Context,
channelID int64,
) (bool, error) {
var isNoExternal int
err := database.conn.QueryRowContext(ctx,
`SELECT is_no_external FROM channels
WHERE id = ?`,
channelID,
).Scan(&isNoExternal)
if err != nil {
return false, fmt.Errorf(
"check no external: %w", err,
)
}
return isNoExternal != 0, nil
}
// SetChannelNoExternal sets or unsets +n mode.
func (database *Database) SetChannelNoExternal(
ctx context.Context,
channelID int64,
noExternal bool,
) error {
val := 0
if noExternal {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_no_external = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf("set no external: %w", err)
}
return nil
}
// ListAllChannelsWithCountsFiltered returns all channels
// with member counts, excluding secret channels that
// the given session is not a member of.

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"
)

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);

View File

@@ -44,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,

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 (
@@ -1528,6 +1528,87 @@ func (hdlr *Handlers) executeJoin(
http.StatusOK)
}
// checkJoinAllowed runs Tier 2 restrictions for an
// existing channel. Returns true if join is allowed.
func (hdlr *Handlers) checkJoinAllowed(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
suppliedKey string,
) bool {
ctx := request.Context()
// 1. Ban check — prevents banned users from joining.
isBanned, banErr := hdlr.params.Database.
IsSessionBanned(ctx, chID, sessionID)
if banErr == nil && isBanned {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrBannedFromChan, nick,
[]string{channel},
"Cannot join channel (+b)",
)
return false
}
// 2. Invite-only check (+i).
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
hasInvite, invErr := hdlr.params.Database.
HasChannelInvite(ctx, chID, sessionID)
if invErr != nil || !hasInvite {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrInviteOnlyChan, nick,
[]string{channel},
"Cannot join channel (+i)",
)
return false
}
}
// 3. Channel key check (+k).
key, keyErr := hdlr.params.Database.
GetChannelKey(ctx, chID)
if keyErr == nil && key != "" {
if suppliedKey != key {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrBadChannelKey, nick,
[]string{channel},
"Cannot join channel (+k)",
)
return false
}
}
// 4. User limit check (+l).
limit, limErr := hdlr.params.Database.
GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 {
count, cntErr := hdlr.params.Database.
CountChannelMembers(ctx, chID)
if cntErr == nil && count >= int64(limit) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrChannelIsFull, nick,
[]string{channel},
"Cannot join channel (+l)",
)
return false
}
}
return true
}
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics(
@@ -2016,14 +2097,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
@@ -3962,76 +4091,3 @@ func (hdlr *Handlers) deliverWhoisIdle(
"seconds idle, signon time",
)
}
// fanOut inserts a message and enqueues it to all given
// sessions.
func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target,
nil, body, meta,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
}
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
"error", enqErr, "session_id", sid)
}
hdlr.broker.Notify(sid)
}
return msgUUID, nil
}
// fanOutSilent calls fanOut and discards the UUID.
func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target,
body, nil, sessionIDs,
)
return err
}
// requireChannelOp checks if the session is a channel
// operator and sends ERR_CHANOPRIVSNEEDED if not.
func (hdlr *Handlers) requireChannelOp(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) bool {
isOp, err := hdlr.params.Database.IsChannelOperator(
request.Context(), chID, sessionID,
)
if err != nil || !isOp {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrChanOpPrivsNeeded, nick,
[]string{channel},
"You're not channel operator",
)
return false
}
return true
}

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) {

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

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")

View File

@@ -5,7 +5,7 @@ import (
"encoding/hex"
"testing"
"sneak.berlin/go/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (

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

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.

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
@@ -179,7 +179,7 @@ func (c *Conn) joinChannel(
channel string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.nick, channel, "",
ctx, c.sessionID, c.nick, channel,
)
if err != nil {
c.sendIRCError(err)
@@ -490,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(
@@ -623,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
}
}

View File

@@ -11,24 +11,23 @@ import (
"sync"
"time"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const (
maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second
dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
maxHashcashBits = 40
maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second
dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
)
// cmdHandler is the signature for registered IRC command
@@ -435,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",
)

View File

@@ -5,10 +5,10 @@ import (
"log/slog"
"net"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/service"
)
// NewTestServer creates a Server suitable for testing.
@@ -19,9 +19,12 @@ func NewTestServer(
database *db.Database,
brk *broker.Broker,
) *Server {
svc := service.NewTestService(
database, brk, cfg, log,
)
svc := &service.Service{
DB: database,
Broker: brk,
Config: cfg,
Log: log,
}
return &Server{ //nolint:exhaustruct
log: log,

View File

@@ -1,913 +0,0 @@
package ircserver_test
import (
"strings"
"testing"
"time"
)
// TestIntegrationTwoClients is a comprehensive integration
// test that spawns the IRC server programmatically, connects
// two real TCP clients, and verifies all major IRC features
// including cross-client message delivery.
//
// The test runs sequentially through IRC features because
// both clients share the same channel state. Each section
// builds on the previous one (e.g. alice and bob must be
// JOINed before PRIVMSG can be tested).
//
//nolint:cyclop,funlen,maintidx // integration test
func TestIntegrationTwoClients(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
bob := env.dial(t)
// ── Registration ──────────────────────────────────
aliceWelcome := alice.register("alice")
assertContains(
t, aliceWelcome, " 001 ", "RPL_WELCOME alice",
)
assertContains(
t, aliceWelcome, " 002 ", "RPL_YOURHOST alice",
)
assertContains(
t, aliceWelcome, " 003 ", "RPL_CREATED alice",
)
assertContains(
t, aliceWelcome, " 004 ", "RPL_MYINFO alice",
)
assertContains(
t, aliceWelcome, "alice",
"nick in welcome burst",
)
bobWelcome := bob.register("bob")
assertContains(
t, bobWelcome, " 001 ", "RPL_WELCOME bob",
)
assertContains(
t, bobWelcome, "bob",
"nick in welcome burst",
)
// ── JOIN and cross-client visibility ──────────────
alice.send("JOIN #integration")
aliceJoinLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceJoinLines, "JOIN",
"alice receives JOIN echo",
)
assertContains(
t, aliceJoinLines, " 366 ",
"RPL_ENDOFNAMES for alice",
)
bob.send("JOIN #integration")
bobJoinLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, bobJoinLines, "JOIN",
"bob receives JOIN echo",
)
// Alice should see bob's JOIN via relay.
aliceSeesBob := alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceSeesBob, "bob",
"alice sees bob's JOIN",
)
// ── PRIVMSG (channel) — alice to bob ──────────────
alice.send("PRIVMSG #integration :hello from alice")
bobGetsMsg := bob.readUntil(func(l string) bool {
return strings.Contains(l, "hello from alice")
})
assertContains(
t, bobGetsMsg, "hello from alice",
"bob receives alice's channel message",
)
// ── PRIVMSG (channel) — bob to alice ──────────────
bob.send("PRIVMSG #integration :hello from bob")
aliceGetsMsg := alice.readUntil(func(l string) bool {
return strings.Contains(l, "hello from bob")
})
assertContains(
t, aliceGetsMsg, "hello from bob",
"alice receives bob's channel message",
)
// ── PRIVMSG (DM) — alice to bob ──────────────────
alice.send("PRIVMSG bob :secret message")
bobDM := bob.readUntil(func(l string) bool {
return strings.Contains(l, "secret message")
})
assertContains(
t, bobDM, "secret message",
"bob receives alice's DM",
)
assertContains(
t, bobDM, "alice",
"DM from field is alice",
)
// ── PRIVMSG (DM) — bob to alice ──────────────────
bob.send("PRIVMSG alice :reply to you")
aliceDM := alice.readUntil(func(l string) bool {
return strings.Contains(l, "reply to you")
})
assertContains(
t, aliceDM, "reply to you",
"alice receives bob's DM",
)
// ── NOTICE (channel) ──────────────────────────────
alice.send("NOTICE #integration :notice msg")
bobNotice := bob.readUntil(func(l string) bool {
return strings.Contains(l, "notice msg")
})
assertContains(
t, bobNotice, "NOTICE",
"bob receives NOTICE command",
)
assertContains(
t, bobNotice, "notice msg",
"bob receives NOTICE text",
)
// ── NOTICE (DM) ──────────────────────────────────
bob.send("NOTICE alice :dm notice")
aliceNotice := alice.readUntil(func(l string) bool {
return strings.Contains(l, "dm notice")
})
assertContains(
t, aliceNotice, "dm notice",
"alice receives DM NOTICE",
)
// ── TOPIC ─────────────────────────────────────────
// alice is the channel creator so she is +o.
alice.send("TOPIC #integration :Integration Test Topic")
aliceTopic := alice.readUntil(func(l string) bool {
return strings.Contains(
l, "Integration Test Topic",
)
})
assertContains(
t, aliceTopic, "Integration Test Topic",
"alice sees TOPIC echo",
)
bobTopic := bob.readUntil(func(l string) bool {
return strings.Contains(
l, "Integration Test Topic",
)
})
assertContains(
t, bobTopic, "Integration Test Topic",
"bob receives TOPIC change",
)
// ── MODE (query) ──────────────────────────────────
alice.send("MODE #integration")
aliceMode := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 324 ")
})
assertContains(
t, aliceMode, " 324 ",
"RPL_CHANNELMODEIS",
)
// ── MODE (+m moderated, then -m) ──────────────────
alice.send("MODE #integration +m")
aliceModeM := alice.readUntil(func(l string) bool {
return strings.Contains(l, "MODE") &&
strings.Contains(l, "+m")
})
assertContains(
t, aliceModeM, "+m",
"alice sees MODE +m echo",
)
bobModeM := bob.readUntil(func(l string) bool {
return strings.Contains(l, "+m")
})
assertContains(
t, bobModeM, "+m",
"bob sees MODE +m relay",
)
// Revert moderated mode.
alice.send("MODE #integration -m")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "-m")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "-m")
})
// ── MODE (+v voice, then -v) ──────────────────────
alice.send("MODE #integration +v bob")
aliceVoice := alice.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
assertContains(
t, aliceVoice, "+v",
"alice sees +v echo",
)
bobVoice := bob.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
assertContains(
t, bobVoice, "+v",
"bob receives +v relay",
)
// Remove voice.
alice.send("MODE #integration -v bob")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "-v")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "-v")
})
// ── NAMES ─────────────────────────────────────────
alice.send("NAMES #integration")
aliceNames := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceNames, " 353 ",
"RPL_NAMREPLY",
)
assertContains(
t, aliceNames, " 366 ",
"RPL_ENDOFNAMES",
)
// Both nicks should appear in the name list.
foundBothNames := false
for _, line := range aliceNames {
if strings.Contains(line, " 353 ") &&
strings.Contains(line, "alice") &&
strings.Contains(line, "bob") {
foundBothNames = true
break
}
}
if !foundBothNames {
t.Error("NAMES reply should list both alice and bob")
}
// ── LIST ──────────────────────────────────────────
alice.send("LIST")
aliceList := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 323 ")
})
assertContains(
t, aliceList, " 322 ",
"RPL_LIST entry",
)
assertContains(
t, aliceList, "#integration",
"LIST includes #integration",
)
assertContains(
t, aliceList, " 323 ", //nolint:misspell // IRC RPL_LISTEND
"RPL_LISTEND", //nolint:misspell // IRC term
)
// ── WHO ───────────────────────────────────────────
bob.send("WHO #integration")
bobWho := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 315 ")
})
assertContains(
t, bobWho, " 352 ",
"RPL_WHOREPLY",
)
assertContains(
t, bobWho, " 315 ",
"RPL_ENDOFWHO",
)
// ── WHOIS ─────────────────────────────────────────
alice.send("WHOIS bob")
aliceWhois := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 318 ")
})
assertContains(
t, aliceWhois, " 311 ",
"RPL_WHOISUSER",
)
assertContains(
t, aliceWhois, " 312 ",
"RPL_WHOISSERVER",
)
assertContains(
t, aliceWhois, " 318 ",
"RPL_ENDOFWHOIS",
)
// ── WHOIS with channels ───────────────────────────
bob.send("WHOIS alice")
bobWhois := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 318 ")
})
assertContains(
t, bobWhois, " 319 ",
"RPL_WHOISCHANNELS",
)
assertContains(
t, bobWhois, "#integration",
"WHOIS shows #integration channel",
)
// ── LUSERS ────────────────────────────────────────
alice.send("LUSERS")
aliceLusers := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 255 ")
})
assertContains(
t, aliceLusers, " 251 ",
"RPL_LUSERCLIENT",
)
assertContains(
t, aliceLusers, " 255 ",
"RPL_LUSERME",
)
// ── NICK change ───────────────────────────────────
bob.send("NICK bobby")
bobNick := bob.readUntil(func(l string) bool {
return strings.Contains(l, "NICK") &&
strings.Contains(l, "bobby")
})
assertContains(
t, bobNick, "bobby",
"bob sees NICK change to bobby",
)
// alice should see the nick change relayed.
aliceNick := alice.readUntil(func(l string) bool {
return strings.Contains(l, "bobby")
})
assertContains(
t, aliceNick, "NICK",
"alice sees NICK command",
)
assertContains(
t, aliceNick, "bobby",
"alice sees new nick bobby",
)
// Change it back for remaining tests.
bob.send("NICK bob")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "bob")
})
alice.readUntil(func(l string) bool {
return strings.Contains(l, "NICK") &&
strings.Contains(l, "bob")
})
// ── Duplicate NICK ────────────────────────────────
bob.send("NICK alice")
bobDupNick := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 433 ")
})
assertContains(
t, bobDupNick, " 433 ",
"ERR_NICKNAMEINUSE",
)
// ── KICK ──────────────────────────────────────────
// alice is op; she kicks bob.
alice.send("KICK #integration bob :testing kick")
aliceKick := alice.readUntil(func(l string) bool {
return strings.Contains(l, "KICK")
})
assertContains(
t, aliceKick, "KICK",
"alice sees KICK echo",
)
assertContains(
t, aliceKick, "bob",
"KICK mentions bob",
)
bobKick := bob.readUntil(func(l string) bool {
return strings.Contains(l, "KICK")
})
assertContains(
t, bobKick, "KICK",
"bob receives KICK",
)
assertContains(
t, bobKick, "testing kick",
"KICK reason is relayed",
)
// bob rejoins.
bob.joinAndDrain("#integration")
// Drain alice's view of the rejoin.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// ── KICK non-op should fail ───────────────────────
bob.send("KICK #integration alice :nope")
bobKickFail := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 482 ")
})
assertContains(
t, bobKickFail, " 482 ",
"ERR_CHANOPRIVSNEEDED",
)
// ── TOPIC lock (+t default) ───────────────────────
// +t is default, so bob should not be able to set
// topic.
bob.send("TOPIC #integration :bob tries topic")
bobTopicFail := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 482 ")
})
assertContains(
t, bobTopicFail, " 482 ",
"ERR_CHANOPRIVSNEEDED for topic",
)
// ── PING / PONG ───────────────────────────────────
alice.send("PING :testtoken")
alicePong := alice.readUntil(func(l string) bool {
return strings.Contains(l, "PONG")
})
assertContains(
t, alicePong, "PONG",
"PONG response received",
)
// ── Unknown command ───────────────────────────────
bob.send("FOOBAR")
bobUnknown := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 421 ")
})
assertContains(
t, bobUnknown, " 421 ",
"ERR_UNKNOWNCOMMAND",
)
// ── MOTD ──────────────────────────────────────────
alice.send("MOTD")
aliceMOTD := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 376 ")
})
assertContains(
t, aliceMOTD, " 376 ",
"RPL_ENDOFMOTD",
)
// ── AWAY (set, check via DM, clear) ───────────────
alice.send("AWAY :gone fishing")
aliceAway := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 306 ")
})
assertContains(
t, aliceAway, " 306 ",
"RPL_NOWAWAY",
)
// bob DMs alice — should get RPL_AWAY.
bob.send("PRIVMSG alice :are you there?")
bobAwayReply := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 301 ")
})
assertContains(
t, bobAwayReply, " 301 ",
"RPL_AWAY for bob when messaging alice",
)
assertContains(
t, bobAwayReply, "gone fishing",
"away message relayed",
)
// Clear away.
alice.send("AWAY")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 305 ")
})
// ── PASS (set password post-registration) ─────────
alice.send("PASS :mypassword123")
alicePass := alice.readUntil(func(l string) bool {
return strings.Contains(l, "Password set")
})
assertContains(
t, alicePass, "Password set",
"password set confirmation",
)
// ── MODE -t/+t topic lock toggle ──────────────────
alice.send("MODE #integration -t")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "-t")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "-t")
})
// Now bob should be able to set topic.
bob.send("TOPIC #integration :bob sets topic now")
bobTopicOK := bob.readUntil(func(l string) bool {
return strings.Contains(l, "bob sets topic now")
})
assertContains(
t, bobTopicOK, "bob sets topic now",
"bob can set topic after -t",
)
// alice sees the topic change.
aliceTopicRelay := alice.readUntil(func(l string) bool {
return strings.Contains(l, "bob sets topic now")
})
assertContains(
t, aliceTopicRelay, "bob sets topic now",
"alice sees bob's topic after -t",
)
// Restore +t.
alice.send("MODE #integration +t")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "+t")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "+t")
})
// ── DM to nonexistent nick ────────────────────────
alice.send("PRIVMSG nobody123 :hello")
aliceNoSuch := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 401 ")
})
assertContains(
t, aliceNoSuch, " 401 ",
"ERR_NOSUCHNICK",
)
// ── PART with reason ──────────────────────────────
bob.send("PART #integration :bye for now")
bobPart := bob.readUntil(func(l string) bool {
return strings.Contains(l, "PART")
})
assertContains(
t, bobPart, "PART",
"bob sees PART echo",
)
// alice sees bob PART via relay.
alicePart := alice.readUntil(func(l string) bool {
return strings.Contains(l, "PART") &&
strings.Contains(l, "bob")
})
assertContains(
t, alicePart, "bob",
"alice sees bob's PART",
)
assertContains(
t, alicePart, "bye for now",
"PART reason is relayed",
)
// bob rejoins for remaining tests.
bob.joinAndDrain("#integration")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// ── PART non-existent channel ─────────────────────
bob.send("PART #nonexistent")
bobPartFail := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 403 ") ||
strings.Contains(l, " 442 ")
})
foundPartErr := false
for _, line := range bobPartFail {
if strings.Contains(line, " 403 ") ||
strings.Contains(line, " 442 ") {
foundPartErr = true
break
}
}
if !foundPartErr {
t.Error(
"expected ERR_NOSUCHCHANNEL or " +
"ERR_NOTONCHANNEL",
)
}
// ── User MODE query ───────────────────────────────
alice.send("MODE alice")
aliceUMode := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ")
})
assertContains(
t, aliceUMode, " 221 ",
"RPL_UMODEIS",
)
// ── Multiple channel operation ────────────────────
alice.send("JOIN #second")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
bob.send("JOIN #second")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
// Drain alice seeing bob join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
alice.send("PRIVMSG #second :cross-channel test")
bobCross := bob.readUntil(func(l string) bool {
return strings.Contains(l, "cross-channel test")
})
assertContains(
t, bobCross, "cross-channel test",
"bob receives message in #second",
)
// Clean up #second.
alice.send("PART #second")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "PART")
})
bob.send("PART #second")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "PART")
})
// ── QUIT ──────────────────────────────────────────
bob.send("QUIT :integration test done")
bobQuit := bob.readUntil(func(l string) bool {
return strings.Contains(l, "ERROR")
})
assertContains(
t, bobQuit, "integration test done",
"QUIT reason echoed",
)
// alice should see bob's QUIT via relay.
aliceQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceQuit, "bob",
"alice sees bob's QUIT",
)
}
// TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is
// reflected in MODE queries.
func TestIntegrationModeSecret(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#secretroom")
// Set +s.
alice.send("MODE #secretroom +s")
aliceLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "+s")
})
assertContains(
t, aliceLines, "+s",
"alice sees MODE +s confirmation",
)
// Verify mode is reflected in query.
alice.send("MODE #secretroom")
modeLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 324 ")
})
assertContains(
t, modeLines, "s",
"channel mode includes s",
)
}
// TestIntegrationModeModerated tests +m (moderated) mode
// — non-voiced users cannot send.
func TestIntegrationModeModerated(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#modtest")
bob.joinAndDrain("#modtest")
// Drain alice's view of bob's join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// Set +m.
alice.send("MODE #modtest +m")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "+m")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "+m")
})
// bob should get an error trying to send.
bob.send("PRIVMSG #modtest :should fail")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 404 ") ||
strings.Contains(l, " 482 ")
})
foundModErr := false
for _, line := range bobLines {
if strings.Contains(line, " 404 ") ||
strings.Contains(line, " 482 ") {
foundModErr = true
break
}
}
if !foundModErr {
t.Error(
"non-voiced user should not be able to send " +
"in +m channel",
)
}
// Grant +v to bob, then he should be able to send.
alice.send("MODE #modtest +v bob")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
bob.send("PRIVMSG #modtest :voiced message")
aliceLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "voiced message")
})
assertContains(
t, aliceLines, "voiced message",
"alice receives voiced bob's message",
)
}
// TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from
// the other two.
func TestIntegrationThirdClientObserver(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
carol := env.dial(t)
carol.register("carol")
alice.joinAndDrain("#trio")
bob.joinAndDrain("#trio")
carol.joinAndDrain("#trio")
// Drain join notifications.
time.Sleep(100 * time.Millisecond)
// alice sends; both bob and carol should receive.
alice.send("PRIVMSG #trio :hello trio")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, "hello trio")
})
assertContains(
t, bobLines, "hello trio",
"bob receives trio message",
)
carolLines := carol.readUntil(func(l string) bool {
return strings.Contains(l, "hello trio")
})
assertContains(
t, carolLines, "hello trio",
"carol receives trio message",
)
}

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

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

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

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"
)

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.

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

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) {

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"

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"

View File

@@ -8,15 +8,14 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
// Params defines the dependencies for creating a Service.
@@ -31,35 +30,19 @@ type Params struct {
// Service provides shared business logic for IRC commands.
type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
log *slog.Logger
DB *db.Database
Broker *broker.Broker
Config *config.Config
Log *slog.Logger
}
// New creates a new Service.
func New(params Params) *Service {
return &Service{
db: params.Database,
broker: params.Broker,
config: params.Config,
log: params.Logger.Get(),
}
}
// NewTestService creates a Service for use in tests
// outside the service package.
func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
log *slog.Logger,
) *Service {
return &Service{
db: database,
broker: brk,
config: cfg,
log: log,
DB: params.Database,
Broker: params.Broker,
Config: params.Config,
Log: params.Logger.Get(),
}
}
@@ -93,7 +76,7 @@ func (s *Service) FanOut(
params, body, meta json.RawMessage,
sessionIDs []int64,
) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage(
dbID, msgUUID, err := s.DB.InsertMessage(
ctx, command, from, to, params, body, meta,
)
if err != nil {
@@ -101,8 +84,8 @@ func (s *Service) FanOut(
}
for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID)
s.broker.Notify(sid)
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
s.Broker.Notify(sid)
}
return dbID, msgUUID, nil
@@ -137,7 +120,7 @@ func (s *Service) SendChannelMessage(
nick, command, channel string,
body, meta json.RawMessage,
) (int64, string, error) {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return 0, "", &IRCError{
irc.ErrNoSuchChannel,
@@ -146,7 +129,7 @@ func (s *Service) SendChannelMessage(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -157,24 +140,12 @@ func (s *Service) SendChannelMessage(
}
}
// Ban check — banned users cannot send messages.
isBanned, banErr := s.db.IsSessionBanned(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+b)",
}
}
moderated, _ := s.db.IsChannelModerated(ctx, chID)
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
if moderated {
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
isVoiced, _ := s.db.IsChannelVoiced(
isVoiced, _ := s.DB.IsChannelVoiced(
ctx, chID, sessionID,
)
@@ -187,7 +158,7 @@ func (s *Service) SendChannelMessage(
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
dbID, uuid, fanErr := s.FanOut(
@@ -210,7 +181,7 @@ func (s *Service) SendDirectMessage(
nick, command, target string,
body, meta json.RawMessage,
) (*DirectMsgResult, error) {
targetSID, err := s.db.GetSessionByNick(ctx, target)
targetSID, err := s.DB.GetSessionByNick(ctx, target)
if err != nil {
return nil, &IRCError{
irc.ErrNoSuchNick,
@@ -219,7 +190,7 @@ func (s *Service) SendDirectMessage(
}
}
away, _ := s.db.GetAway(ctx, targetSID)
away, _ := s.DB.GetAway(ctx, targetSID)
recipients := []int64{targetSID}
if targetSID != sessionID {
@@ -243,43 +214,31 @@ func (s *Service) SendDirectMessage(
func (s *Service) JoinChannel(
ctx context.Context,
sessionID int64,
nick, channel, suppliedKey string,
nick, channel 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,
channel, suppliedKey, memberCount,
); joinErr != nil {
return nil, joinErr
}
}
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 {
return nil, fmt.Errorf("join channel: %w", err)
}
// Clear invite after successful join.
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -301,7 +260,7 @@ func (s *Service) PartChannel(
sessionID int64,
nick, channel, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -310,7 +269,7 @@ func (s *Service) PartChannel(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -321,7 +280,7 @@ func (s *Service) PartChannel(
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
@@ -330,8 +289,8 @@ func (s *Service) PartChannel(
nil, body, nil, recipients,
)
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil
}
@@ -343,7 +302,7 @@ func (s *Service) SetTopic(
sessionID int64,
nick, channel, topic string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -352,7 +311,7 @@ func (s *Service) SetTopic(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -363,9 +322,9 @@ func (s *Service) SetTopic(
}
}
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
if topicLocked {
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -377,15 +336,15 @@ func (s *Service) SetTopic(
}
}
if setErr := s.db.SetTopic(
if setErr := s.DB.SetTopic(
ctx, channel, topic,
); setErr != nil {
return fmt.Errorf("set topic: %w", setErr)
}
_ = s.db.SetTopicMeta(ctx, channel, topic, nick)
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -404,7 +363,7 @@ func (s *Service) KickUser(
sessionID int64,
nick, channel, targetNick, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -413,7 +372,7 @@ func (s *Service) KickUser(
}
}
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -424,7 +383,7 @@ func (s *Service) KickUser(
}
}
targetSID, err := s.db.GetSessionByNick(
targetSID, err := s.DB.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -435,7 +394,7 @@ func (s *Service) KickUser(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -446,7 +405,7 @@ func (s *Service) KickUser(
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
params, _ := json.Marshal( //nolint:errchkjson
[]string{targetNick},
@@ -457,8 +416,8 @@ func (s *Service) KickUser(
params, body, nil, memberIDs,
)
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil
}
@@ -470,7 +429,7 @@ func (s *Service) ChangeNick(
sessionID int64,
oldNick, newNick string,
) error {
err := s.db.ChangeNick(ctx, sessionID, newNick)
err := s.DB.ChangeNick(ctx, sessionID, newNick)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") ||
db.IsUniqueConstraintError(err) {
@@ -502,7 +461,7 @@ func (s *Service) BroadcastQuit(
sessionID int64,
nick, reason string,
) {
channels, err := s.db.GetSessionChannels(
channels, err := s.DB.GetSessionChannels(
ctx, sessionID,
)
if err != nil {
@@ -512,7 +471,7 @@ func (s *Service) BroadcastQuit(
notified := make(map[int64]bool)
for _, ch := range channels {
memberIDs, memErr := s.db.GetChannelMemberIDs(
memberIDs, memErr := s.DB.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
@@ -543,11 +502,11 @@ func (s *Service) BroadcastQuit(
}
for _, ch := range channels {
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
}
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
}
// SetAway sets or clears the away message. Returns true
@@ -557,7 +516,7 @@ func (s *Service) SetAway(
sessionID int64,
message string,
) (bool, error) {
err := s.db.SetAway(ctx, sessionID, message)
err := s.DB.SetAway(ctx, sessionID, message)
if err != nil {
return false, fmt.Errorf("set away: %w", err)
}
@@ -572,8 +531,8 @@ func (s *Service) Oper(
sessionID int64,
name, password string,
) error {
cfgName := s.config.OperName
cfgPassword := s.config.OperPassword
cfgName := s.Config.OperName
cfgPassword := s.Config.OperPassword
// Use constant-time comparison and return the same
// error for all failures to prevent information
@@ -592,7 +551,7 @@ func (s *Service) Oper(
}
}
_ = s.db.SetSessionOper(ctx, sessionID, true)
_ = s.DB.SetSessionOper(ctx, sessionID, true)
return nil
}
@@ -604,7 +563,7 @@ func (s *Service) ValidateChannelOp(
sessionID int64,
channel string,
) (int64, error) {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return 0, &IRCError{
irc.ErrNoSuchChannel,
@@ -613,7 +572,7 @@ func (s *Service) ValidateChannelOp(
}
}
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -636,7 +595,7 @@ func (s *Service) ApplyMemberMode(
mode rune,
adding bool,
) error {
targetSID, err := s.db.GetSessionByNick(
targetSID, err := s.DB.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -647,7 +606,7 @@ func (s *Service) ApplyMemberMode(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -660,11 +619,11 @@ func (s *Service) ApplyMemberMode(
switch mode {
case 'o':
_ = s.db.SetChannelMemberOperator(
_ = s.DB.SetChannelMemberOperator(
ctx, chID, targetSID, adding,
)
case 'v':
_ = s.db.SetChannelMemberVoiced(
_ = s.DB.SetChannelMemberVoiced(
ctx, chID, targetSID, adding,
)
}
@@ -672,8 +631,7 @@ func (s *Service) ApplyMemberMode(
return nil
}
// SetChannelFlag applies a simple boolean channel mode
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
// SetChannelFlag applies +m/-m or +t/-t on a channel.
func (s *Service) SetChannelFlag(
ctx context.Context,
chID int64,
@@ -682,37 +640,17 @@ 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(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set invite only: %w", err)
}
case 's':
if err := s.db.SetChannelSecret(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set secret: %w", err)
}
case 'n':
if err := s.db.SetChannelNoExternal(
ctx, chID, setting,
); err != nil {
return fmt.Errorf(
"set no external: %w", err,
)
}
}
return nil
@@ -726,7 +664,7 @@ func (s *Service) BroadcastMode(
chID int64,
modeText string,
) {
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -735,60 +673,24 @@ func (s *Service) BroadcastMode(
)
}
// QueryChannelMode returns the complete channel mode
// string including all flags and parameterized modes.
// QueryChannelMode returns the channel mode string.
func (s *Service) QueryChannelMode(
ctx context.Context,
chID int64,
) string {
modes := "+"
noExternal, _ := s.db.IsChannelNoExternal(ctx, chID)
if noExternal {
modes += "n"
}
inviteOnly, _ := s.db.IsChannelInviteOnly(ctx, chID)
if inviteOnly {
modes += "i"
}
moderated, _ := s.db.IsChannelModerated(ctx, chID)
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
if moderated {
modes += "m"
}
secret, _ := s.db.IsChannelSecret(ctx, chID)
if secret {
modes += "s"
}
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
if topicLocked {
modes += "t"
}
var modeParams string
key, _ := s.db.GetChannelKey(ctx, chID)
if key != "" {
modes += "k"
modeParams += " " + key
}
limit, _ := s.db.GetChannelUserLimit(ctx, chID)
if limit > 0 {
modes += "l"
modeParams += " " + strconv.Itoa(limit)
}
bits, _ := s.db.GetChannelHashcashBits(ctx, chID)
if bits > 0 {
modes += "H"
modeParams += " " + strconv.Itoa(bits)
}
return modes + modeParams
return modes
}
// broadcastNickChange notifies channel peers of a nick
@@ -798,7 +700,7 @@ func (s *Service) broadcastNickChange(
sessionID int64,
oldNick, newNick string,
) {
channels, err := s.db.GetSessionChannels(
channels, err := s.DB.GetSessionChannels(
ctx, sessionID,
)
if err != nil {
@@ -808,7 +710,7 @@ func (s *Service) broadcastNickChange(
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
notified := make(map[int64]bool)
dbID, _, insErr := s.db.InsertMessage(
dbID, _, insErr := s.DB.InsertMessage(
ctx, irc.CmdNick, oldNick, "",
nil, body, nil,
)
@@ -817,12 +719,12 @@ func (s *Service) broadcastNickChange(
}
// Notify the user themselves (for multi-client sync).
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID)
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
s.Broker.Notify(sessionID)
notified[sessionID] = true
for _, ch := range channels {
memberIDs, memErr := s.db.GetChannelMemberIDs(
memberIDs, memErr := s.DB.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
@@ -836,66 +738,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)
}
}
}
// checkJoinRestrictions validates Tier 2 join conditions:
// bans, invite-only, channel key, and user limit.
func checkJoinRestrictions(
ctx context.Context,
database *db.Database,
chID, sessionID int64,
channel, suppliedKey string,
memberCount int64,
) error {
isBanned, banErr := database.IsSessionBanned(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return &IRCError{
Code: irc.ErrBannedFromChan,
Params: []string{channel},
Message: "Cannot join channel (+b)",
}
}
isInviteOnly, ioErr := database.IsChannelInviteOnly(
ctx, chID,
)
if ioErr == nil && isInviteOnly {
hasInvite, invErr := database.HasChannelInvite(
ctx, chID, sessionID,
)
if invErr != nil || !hasInvite {
return &IRCError{
Code: irc.ErrInviteOnlyChan,
Params: []string{channel},
Message: "Cannot join channel (+i)",
}
}
}
key, keyErr := database.GetChannelKey(ctx, chID)
if keyErr == nil && key != "" && suppliedKey != key {
return &IRCError{
Code: irc.ErrBadChannelKey,
Params: []string{channel},
Message: "Cannot join channel (+k)",
}
}
limit, limErr := database.GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 &&
memberCount >= int64(limit) {
return &IRCError{
Code: irc.ErrChannelIsFull,
Params: []string{channel},
Message: "Cannot join channel (+l)",
}
}
return nil
}

View File

@@ -12,16 +12,16 @@ import (
"os"
"testing"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMain(m *testing.M) {
@@ -167,7 +167,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
ctx, sid, "alice", "#general",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -185,7 +185,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel(
ctx, sid2, "bob", "#general", "",
ctx, sid2, "bob", "#general",
)
if err != nil {
t.Fatalf("JoinChannel bob: %v", err)
@@ -207,7 +207,7 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
ctx, sid, "alice", "#general",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -242,14 +242,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#chat", "",
ctx, sid1, "alice", "#chat",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#chat", "",
ctx, sid2, "bob", "#chat",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -293,14 +293,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#room", "",
ctx, sid1, "alice", "#room",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#room", "",
ctx, sid2, "bob", "#room",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -326,14 +326,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#modchat", "",
ctx, sid1, "alice", "#modchat",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#modchat", "",
ctx, sid2, "bob", "#modchat",
)
if err != nil {
t.Fatalf("join bob: %v", err)

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) {

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) {

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",

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",