refactor: shared service layer, default IRC port, smaller functions
All checks were successful
check / check (push) Successful in 2m37s
All checks were successful
check / check (push) Successful in 2m37s
Wire up service.Service in HTTP handlers and delegate cleanupUser to svc.BroadcastQuit for consistent quit/part logic across transports. Default IRC_LISTEN_ADDR to :6667, remove unused import, fix all lint issues (dogsled, funcorder, wrapcheck, varnamelen, nolintlint). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2228,7 +2228,7 @@ directory is also loaded automatically via
|
|||||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||||
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||||
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||||
| `IRC_LISTEN_ADDR` | string | `""` | TCP address for the traditional IRC protocol listener (e.g. `:6667`). Disabled if empty. |
|
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,7 @@ func main() {
|
|||||||
server.New,
|
server.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
service.New,
|
||||||
stats.New,
|
stats.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(
|
fx.Invoke(func(
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func New(
|
|||||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||||
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||||
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||||
viper.SetDefault("IRC_LISTEN_ADDR", "")
|
viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3547,52 +3547,16 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cleanupUser parts the user from all channels (notifying
|
// cleanupUser parts the user from all channels (notifying
|
||||||
// members) and deletes the session.
|
// members) and deletes the session via the shared service
|
||||||
|
// layer.
|
||||||
func (hdlr *Handlers) cleanupUser(
|
func (hdlr *Handlers) cleanupUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick string,
|
nick string,
|
||||||
) {
|
) {
|
||||||
channels, _ := hdlr.params.Database.
|
hdlr.svc.BroadcastQuit(
|
||||||
GetSessionChannels(ctx, sessionID)
|
ctx, sessionID, nick, "Connection closed",
|
||||||
|
|
||||||
notified := map[int64]bool{}
|
|
||||||
|
|
||||||
var quitDBID int64
|
|
||||||
|
|
||||||
if len(channels) > 0 {
|
|
||||||
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
|
||||||
ctx, irc.CmdQuit, nick, "",
|
|
||||||
nil, nil, nil,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
for _, chanInfo := range channels {
|
|
||||||
memberIDs, _ := hdlr.params.Database.
|
|
||||||
GetChannelMemberIDs(ctx, chanInfo.ID)
|
|
||||||
|
|
||||||
for _, mid := range memberIDs {
|
|
||||||
if mid != sessionID && !notified[mid] {
|
|
||||||
notified[mid] = true
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.EnqueueToSession(
|
|
||||||
ctx, mid, quitDBID,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(mid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.PartChannel(
|
|
||||||
ctx, chanInfo.ID, sessionID,
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.DeleteChannelIfEmpty(
|
|
||||||
ctx, chanInfo.ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.DeleteSession(ctx, sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleUsersMe returns the current user's session info.
|
// HandleUsersMe returns the current user's session info.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,7 @@ type Params struct {
|
|||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Stats *stats.Tracker
|
Stats *stats.Tracker
|
||||||
Broker *broker.Broker
|
Broker *broker.Broker
|
||||||
|
Service *service.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
@@ -49,6 +51,7 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
svc *service.Service
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
channelHashcash *hashcash.ChannelValidator
|
channelHashcash *hashcash.ChannelValidator
|
||||||
loginLimiter *ratelimit.Limiter
|
loginLimiter *ratelimit.Limiter
|
||||||
@@ -81,6 +84,7 @@ func New(
|
|||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: params.Broker,
|
broker: params.Broker,
|
||||||
|
svc: params.Service,
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
channelHashcash: hashcash.NewChannelValidator(),
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ package ircserver
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -15,6 +14,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +30,10 @@ const (
|
|||||||
minPasswordLen = 8
|
minPasswordLen = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cmdHandler is the signature for registered IRC command
|
||||||
|
// handlers.
|
||||||
|
type cmdHandler func(ctx context.Context, msg *Message)
|
||||||
|
|
||||||
// Conn represents a single IRC client TCP connection.
|
// Conn represents a single IRC client TCP connection.
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -37,7 +41,9 @@ type Conn struct {
|
|||||||
database *db.Database
|
database *db.Database
|
||||||
brk *broker.Broker
|
brk *broker.Broker
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
svc *service.Service
|
||||||
serverSfx string
|
serverSfx string
|
||||||
|
commands map[string]cmdHandler
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nick string
|
nick string
|
||||||
@@ -65,6 +71,7 @@ func newConn(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
svc *service.Service,
|
||||||
) *Conn {
|
) *Conn {
|
||||||
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
|
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
|
||||||
|
|
||||||
@@ -73,16 +80,57 @@ func newConn(
|
|||||||
srvName = "neoirc"
|
srvName = "neoirc"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Conn{ //nolint:exhaustruct // zero-value defaults
|
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
|
||||||
conn: tcpConn,
|
conn: tcpConn,
|
||||||
log: log,
|
log: log,
|
||||||
database: database,
|
database: database,
|
||||||
brk: brk,
|
brk: brk,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
svc: svc,
|
||||||
serverSfx: srvName,
|
serverSfx: srvName,
|
||||||
remoteIP: host,
|
remoteIP: host,
|
||||||
hostname: resolveHost(ctx, host),
|
hostname: resolveHost(ctx, host),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn.commands = conn.buildCommandMap()
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCommandMap returns a map from IRC command strings
|
||||||
|
// to handler functions.
|
||||||
|
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||||
|
return map[string]cmdHandler{
|
||||||
|
irc.CmdPing: func(_ context.Context, msg *Message) {
|
||||||
|
c.handlePing(msg)
|
||||||
|
},
|
||||||
|
"PONG": func(context.Context, *Message) {},
|
||||||
|
irc.CmdNick: c.handleNick,
|
||||||
|
irc.CmdPrivmsg: c.handlePrivmsg,
|
||||||
|
irc.CmdNotice: c.handlePrivmsg,
|
||||||
|
irc.CmdJoin: c.handleJoin,
|
||||||
|
irc.CmdPart: c.handlePart,
|
||||||
|
irc.CmdQuit: func(_ context.Context, msg *Message) {
|
||||||
|
c.handleQuit(msg)
|
||||||
|
},
|
||||||
|
irc.CmdTopic: c.handleTopic,
|
||||||
|
irc.CmdMode: c.handleMode,
|
||||||
|
irc.CmdNames: c.handleNames,
|
||||||
|
irc.CmdList: func(ctx context.Context, _ *Message) { c.handleList(ctx) },
|
||||||
|
irc.CmdWhois: c.handleWhois,
|
||||||
|
irc.CmdWho: c.handleWho,
|
||||||
|
irc.CmdLusers: func(ctx context.Context, _ *Message) { c.handleLusers(ctx) },
|
||||||
|
irc.CmdMotd: func(context.Context, *Message) { c.deliverMOTD() },
|
||||||
|
irc.CmdOper: c.handleOper,
|
||||||
|
irc.CmdAway: c.handleAway,
|
||||||
|
irc.CmdKick: c.handleKick,
|
||||||
|
irc.CmdPass: c.handlePassPostReg,
|
||||||
|
"INVITE": c.handleInvite,
|
||||||
|
"CAP": func(_ context.Context, msg *Message) {
|
||||||
|
c.handleCAP(msg)
|
||||||
|
},
|
||||||
|
"USERHOST": c.handleUserhost,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveHost does a reverse DNS lookup, returning the IP
|
// resolveHost does a reverse DNS lookup, returning the IP
|
||||||
@@ -145,71 +193,14 @@ func (c *Conn) cleanup(ctx context.Context) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if wasRegistered && sessID > 0 {
|
if wasRegistered && sessID > 0 {
|
||||||
c.broadcastQuit(ctx, nick, "Connection closed")
|
c.svc.BroadcastQuit(
|
||||||
c.database.DeleteSession(ctx, sessID) //nolint:errcheck,gosec
|
ctx, sessID, nick, "Connection closed",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.conn.Close() //nolint:errcheck,gosec
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) broadcastQuit(
|
|
||||||
ctx context.Context,
|
|
||||||
nick, reason string,
|
|
||||||
) {
|
|
||||||
channels, err := c.database.GetSessionChannels(
|
|
||||||
ctx, c.sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notified := make(map[int64]bool)
|
|
||||||
|
|
||||||
for _, ch := range channels {
|
|
||||||
chID, getErr := c.database.GetChannelByName(
|
|
||||||
ctx, ch.Name,
|
|
||||||
)
|
|
||||||
if getErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
memberIDs, memErr := c.database.GetChannelMemberIDs(
|
|
||||||
ctx, chID,
|
|
||||||
)
|
|
||||||
if memErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mid := range memberIDs {
|
|
||||||
if mid == c.sessionID || notified[mid] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
notified[mid] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
|
||||||
|
|
||||||
for sid := range notified {
|
|
||||||
dbID, _, insErr := c.database.InsertMessage(
|
|
||||||
ctx, irc.CmdQuit, nick, "", nil, body, nil,
|
|
||||||
)
|
|
||||||
if insErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = c.database.EnqueueToSession(ctx, sid, dbID)
|
|
||||||
c.brk.Notify(sid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Part from all channels so they get cleaned up.
|
|
||||||
for _, ch := range channels {
|
|
||||||
c.database.PartChannel(ctx, ch.ID, c.sessionID) //nolint:errcheck,gosec
|
|
||||||
c.database.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// send writes a formatted IRC line to the connection.
|
// send writes a formatted IRC line to the connection.
|
||||||
func (c *Conn) send(line string) {
|
func (c *Conn) send(line string) {
|
||||||
_ = c.conn.SetWriteDeadline(
|
_ = c.conn.SetWriteDeadline(
|
||||||
@@ -261,9 +252,8 @@ func (c *Conn) hostmask() string {
|
|||||||
return c.nick + "!" + user + "@" + host
|
return c.nick + "!" + user + "@" + host
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMessage dispatches a parsed IRC message.
|
// handleMessage dispatches a parsed IRC message using
|
||||||
//
|
// the command handler map.
|
||||||
//nolint:cyclop // dispatch table is inherently branchy
|
|
||||||
func (c *Conn) handleMessage(
|
func (c *Conn) handleMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg *Message,
|
msg *Message,
|
||||||
@@ -276,57 +266,17 @@ func (c *Conn) handleMessage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg.Command {
|
handler, ok := c.commands[msg.Command]
|
||||||
case irc.CmdPing:
|
if !ok {
|
||||||
c.handlePing(msg)
|
|
||||||
case "PONG":
|
|
||||||
// Silently accept.
|
|
||||||
case irc.CmdNick:
|
|
||||||
c.handleNick(ctx, msg)
|
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
|
||||||
c.handlePrivmsg(ctx, msg)
|
|
||||||
case irc.CmdJoin:
|
|
||||||
c.handleJoin(ctx, msg)
|
|
||||||
case irc.CmdPart:
|
|
||||||
c.handlePart(ctx, msg)
|
|
||||||
case irc.CmdQuit:
|
|
||||||
c.handleQuit(msg)
|
|
||||||
case irc.CmdTopic:
|
|
||||||
c.handleTopic(ctx, msg)
|
|
||||||
case irc.CmdMode:
|
|
||||||
c.handleMode(ctx, msg)
|
|
||||||
case irc.CmdNames:
|
|
||||||
c.handleNames(ctx, msg)
|
|
||||||
case irc.CmdList:
|
|
||||||
c.handleList(ctx)
|
|
||||||
case irc.CmdWhois:
|
|
||||||
c.handleWhois(ctx, msg)
|
|
||||||
case irc.CmdWho:
|
|
||||||
c.handleWho(ctx, msg)
|
|
||||||
case irc.CmdLusers:
|
|
||||||
c.handleLusers(ctx)
|
|
||||||
case irc.CmdMotd:
|
|
||||||
c.deliverMOTD()
|
|
||||||
case irc.CmdOper:
|
|
||||||
c.handleOper(ctx, msg)
|
|
||||||
case irc.CmdAway:
|
|
||||||
c.handleAway(ctx, msg)
|
|
||||||
case irc.CmdKick:
|
|
||||||
c.handleKick(ctx, msg)
|
|
||||||
case irc.CmdPass:
|
|
||||||
c.handlePassPostReg(ctx, msg)
|
|
||||||
case "INVITE":
|
|
||||||
c.handleInvite(ctx, msg)
|
|
||||||
case "CAP":
|
|
||||||
c.handleCAP(msg)
|
|
||||||
case "USERHOST":
|
|
||||||
c.handleUserhost(ctx, msg)
|
|
||||||
default:
|
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrUnknownCommand,
|
irc.ErrUnknownCommand,
|
||||||
msg.Command, "Unknown command",
|
msg.Command, "Unknown command",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handler(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePreRegistration handles messages before the
|
// handlePreRegistration handles messages before the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestServer creates a Server suitable for testing.
|
// NewTestServer creates a Server suitable for testing.
|
||||||
@@ -18,11 +19,19 @@ func NewTestServer(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
) *Server {
|
) *Server {
|
||||||
|
svc := &service.Service{
|
||||||
|
DB: database,
|
||||||
|
Broker: brk,
|
||||||
|
Config: cfg,
|
||||||
|
Log: log,
|
||||||
|
}
|
||||||
|
|
||||||
return &Server{ //nolint:exhaustruct
|
return &Server{ //nolint:exhaustruct
|
||||||
log: log,
|
log: log,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
database: database,
|
database: database,
|
||||||
brk: brk,
|
brk: brk,
|
||||||
|
svc: svc,
|
||||||
conns: make(map[*Conn]struct{}),
|
conns: make(map[*Conn]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Broker *broker.Broker
|
Broker *broker.Broker
|
||||||
|
Service *service.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the TCP IRC protocol server.
|
// Server is the TCP IRC protocol server.
|
||||||
@@ -31,6 +33,7 @@ type Server struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
database *db.Database
|
database *db.Database
|
||||||
brk *broker.Broker
|
brk *broker.Broker
|
||||||
|
svc *service.Service
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
conns map[*Conn]struct{}
|
conns map[*Conn]struct{}
|
||||||
@@ -49,6 +52,7 @@ func New(
|
|||||||
cfg: params.Config,
|
cfg: params.Config,
|
||||||
database: params.Database,
|
database: params.Database,
|
||||||
brk: params.Broker,
|
brk: params.Broker,
|
||||||
|
svc: params.Service,
|
||||||
conns: make(map[*Conn]struct{}),
|
conns: make(map[*Conn]struct{}),
|
||||||
listener: nil,
|
listener: nil,
|
||||||
cancel: nil,
|
cancel: nil,
|
||||||
@@ -133,7 +137,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
|
|||||||
|
|
||||||
client := newConn(
|
client := newConn(
|
||||||
ctx, tcpConn, s.log,
|
ctx, tcpConn, s.log,
|
||||||
s.database, s.brk, s.cfg,
|
s.database, s.brk, s.cfg, s.svc,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
739
internal/service/service.go
Normal file
739
internal/service/service.go
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
// Package service provides shared business logic for both
|
||||||
|
// the IRC wire protocol and HTTP/JSON transports.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines the dependencies for creating a Service.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Logger *logger.Logger
|
||||||
|
Config *config.Config
|
||||||
|
Database *db.Database
|
||||||
|
Broker *broker.Broker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service provides shared business logic for IRC commands.
|
||||||
|
type Service struct {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCError represents an IRC protocol-level error with a
|
||||||
|
// numeric code that both transports can map to responses.
|
||||||
|
type IRCError struct {
|
||||||
|
Code irc.IRCMessageType
|
||||||
|
Params []string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *IRCError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
// JoinResult contains the outcome of a channel join.
|
||||||
|
type JoinResult struct {
|
||||||
|
ChannelID int64
|
||||||
|
IsCreator bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectMsgResult contains the outcome of a direct message.
|
||||||
|
type DirectMsgResult struct {
|
||||||
|
UUID string
|
||||||
|
AwayMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FanOut inserts a message and enqueues it to all given
|
||||||
|
// session IDs, notifying each via the broker.
|
||||||
|
func (s *Service) FanOut(
|
||||||
|
ctx context.Context,
|
||||||
|
command, from, to string,
|
||||||
|
params, body, meta json.RawMessage,
|
||||||
|
sessionIDs []int64,
|
||||||
|
) (int64, string, error) {
|
||||||
|
dbID, msgUUID, err := s.DB.InsertMessage(
|
||||||
|
ctx, command, from, to, params, body, meta,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("insert message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sid := range sessionIDs {
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||||
|
s.Broker.Notify(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbID, msgUUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// excludeSession returns a copy of ids without the given
|
||||||
|
// session.
|
||||||
|
func excludeSession(
|
||||||
|
ids []int64,
|
||||||
|
exclude int64,
|
||||||
|
) []int64 {
|
||||||
|
out := make([]int64, 0, len(ids))
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
if id != exclude {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChannelMessage validates membership and moderation,
|
||||||
|
// then fans out a message to all channel members except
|
||||||
|
// the sender.
|
||||||
|
func (s *Service) SendChannelMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, command, channel string,
|
||||||
|
body, meta json.RawMessage,
|
||||||
|
) (string, error) {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return "", &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return "", &IRCError{
|
||||||
|
irc.ErrCannotSendToChan,
|
||||||
|
[]string{channel},
|
||||||
|
"Cannot send to channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||||
|
if moderated {
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
isVoiced, _ := s.DB.IsChannelVoiced(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !isOp && !isVoiced {
|
||||||
|
return "", &IRCError{
|
||||||
|
irc.ErrCannotSendToChan,
|
||||||
|
[]string{channel},
|
||||||
|
"Cannot send to channel (+m)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
|
||||||
|
_, uuid, fanErr := s.FanOut(
|
||||||
|
ctx, command, nick, channel,
|
||||||
|
nil, body, meta, recipients,
|
||||||
|
)
|
||||||
|
if fanErr != nil {
|
||||||
|
return "", fanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDirectMessage validates the target and sends a
|
||||||
|
// direct message, returning the message UUID and any away
|
||||||
|
// message set on the target.
|
||||||
|
func (s *Service) SendDirectMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, command, target string,
|
||||||
|
body, meta json.RawMessage,
|
||||||
|
) (*DirectMsgResult, error) {
|
||||||
|
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{target},
|
||||||
|
"No such nick",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
away, _ := s.DB.GetAway(ctx, targetSID)
|
||||||
|
|
||||||
|
recipients := []int64{targetSID}
|
||||||
|
if targetSID != sessionID {
|
||||||
|
recipients = append(recipients, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, uuid, fanErr := s.FanOut(
|
||||||
|
ctx, command, nick, target,
|
||||||
|
nil, body, meta, recipients,
|
||||||
|
)
|
||||||
|
if fanErr != nil {
|
||||||
|
return nil, fanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DirectMsgResult{UUID: uuid, AwayMsg: away}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinChannel creates or joins a channel, making the
|
||||||
|
// first joiner the operator. Fans out the JOIN to all
|
||||||
|
// channel members.
|
||||||
|
func (s *Service) JoinChannel(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel string,
|
||||||
|
) (*JoinResult, error) {
|
||||||
|
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberCount, countErr := s.DB.CountChannelMembers(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
isCreator := countErr == nil && memberCount == 0
|
||||||
|
|
||||||
|
if isCreator {
|
||||||
|
err = s.DB.JoinChannelAsOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("join channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdJoin, nick, channel,
|
||||||
|
nil, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &JoinResult{
|
||||||
|
ChannelID: chID,
|
||||||
|
IsCreator: isCreator,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartChannel validates membership, broadcasts PART to
|
||||||
|
// remaining members, removes the user, and cleans up empty
|
||||||
|
// channels.
|
||||||
|
func (s *Service) PartChannel(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel, reason string,
|
||||||
|
) error {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNotOnChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdPart, nick, channel,
|
||||||
|
nil, body, nil, recipients,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||||
|
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTopic validates membership and topic-lock, sets the
|
||||||
|
// topic, and broadcasts the change.
|
||||||
|
func (s *Service) SetTopic(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel, topic string,
|
||||||
|
) error {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNotOnChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||||
|
if topicLocked {
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isOp {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrChanOpPrivsNeeded,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if setErr := s.DB.SetTopic(
|
||||||
|
ctx, channel, topic,
|
||||||
|
); setErr != nil {
|
||||||
|
return fmt.Errorf("set topic: %w", setErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdTopic, nick, channel,
|
||||||
|
nil, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickUser validates operator status and target
|
||||||
|
// membership, broadcasts the KICK, removes the target,
|
||||||
|
// and cleans up empty channels.
|
||||||
|
func (s *Service) KickUser(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel, targetNick, reason string,
|
||||||
|
) error {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isOp {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrChanOpPrivsNeeded,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, err := s.DB.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{targetNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, targetSID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrUserNotInChannel,
|
||||||
|
[]string{targetNick, channel},
|
||||||
|
"They aren't on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
params, _ := json.Marshal( //nolint:errchkjson
|
||||||
|
[]string{targetNick},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdKick, nick, channel,
|
||||||
|
params, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||||
|
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeNick changes a user's nickname and broadcasts the
|
||||||
|
// change to all users sharing channels.
|
||||||
|
func (s *Service) ChangeNick(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
oldNick, newNick string,
|
||||||
|
) error {
|
||||||
|
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") ||
|
||||||
|
db.IsUniqueConstraintError(err) {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNicknameInUse,
|
||||||
|
[]string{newNick},
|
||||||
|
"Nickname is already in use",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrErroneusNickname,
|
||||||
|
[]string{newNick},
|
||||||
|
"Erroneous nickname",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
||||||
|
// parts all channels, and deletes the session.
|
||||||
|
func (s *Service) BroadcastQuit(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, reason string,
|
||||||
|
) {
|
||||||
|
channels, err := s.DB.GetSessionChannels(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||||
|
ctx, ch.ID,
|
||||||
|
)
|
||||||
|
if memErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mid := range memberIDs {
|
||||||
|
if mid == sessionID || notified[mid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
notified[mid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
|
for sid := range notified {
|
||||||
|
dbID, _, insErr := s.DB.InsertMessage(
|
||||||
|
ctx, irc.CmdQuit, nick, "",
|
||||||
|
nil, body, nil,
|
||||||
|
)
|
||||||
|
if insErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||||
|
s.Broker.Notify(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAway sets or clears the away message. Returns true
|
||||||
|
// if the message was cleared (empty string).
|
||||||
|
func (s *Service) SetAway(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
message string,
|
||||||
|
) (bool, error) {
|
||||||
|
err := s.DB.SetAway(ctx, sessionID, message)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("set away: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return message == "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oper validates operator credentials and grants oper
|
||||||
|
// status to the session.
|
||||||
|
func (s *Service) Oper(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
name, password string,
|
||||||
|
) error {
|
||||||
|
cfgName := s.Config.OperName
|
||||||
|
cfgPassword := s.Config.OperPassword
|
||||||
|
|
||||||
|
if cfgName == "" || cfgPassword == "" {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoOperHost,
|
||||||
|
nil,
|
||||||
|
"No O-lines for your host",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name != cfgName || password != cfgPassword {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrPasswdMismatch,
|
||||||
|
nil,
|
||||||
|
"Password incorrect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateChannelOp checks that the session is a channel
|
||||||
|
// operator. Returns the channel ID.
|
||||||
|
func (s *Service) ValidateChannelOp(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
channel string,
|
||||||
|
) (int64, error) {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return 0, &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isOp {
|
||||||
|
return 0, &IRCError{
|
||||||
|
irc.ErrChanOpPrivsNeeded,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMemberMode applies +o/-o or +v/-v on a channel
|
||||||
|
// member after validating the target.
|
||||||
|
func (s *Service) ApplyMemberMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
channel, targetNick string,
|
||||||
|
mode rune,
|
||||||
|
adding bool,
|
||||||
|
) error {
|
||||||
|
targetSID, err := s.DB.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{targetNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, targetSID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrUserNotInChannel,
|
||||||
|
[]string{targetNick, channel},
|
||||||
|
"They aren't on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case 'o':
|
||||||
|
_ = s.DB.SetChannelMemberOperator(
|
||||||
|
ctx, chID, targetSID, adding,
|
||||||
|
)
|
||||||
|
case 'v':
|
||||||
|
_ = s.DB.SetChannelMemberVoiced(
|
||||||
|
ctx, chID, targetSID, adding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
||||||
|
func (s *Service) SetChannelFlag(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
flag rune,
|
||||||
|
setting bool,
|
||||||
|
) error {
|
||||||
|
switch flag {
|
||||||
|
case 'm':
|
||||||
|
if err := s.DB.SetChannelModerated(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set moderated: %w", err)
|
||||||
|
}
|
||||||
|
case 't':
|
||||||
|
if err := s.DB.SetChannelTopicLocked(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set topic locked: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastMode fans out a MODE change to all channel
|
||||||
|
// members.
|
||||||
|
func (s *Service) BroadcastMode(
|
||||||
|
ctx context.Context,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
modeText string,
|
||||||
|
) {
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdMode, nick, channel,
|
||||||
|
nil, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryChannelMode returns the channel mode string.
|
||||||
|
func (s *Service) QueryChannelMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
) string {
|
||||||
|
modes := "+"
|
||||||
|
|
||||||
|
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||||
|
if moderated {
|
||||||
|
modes += "m"
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||||
|
if topicLocked {
|
||||||
|
modes += "t"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
|
// change.
|
||||||
|
func (s *Service) broadcastNickChange(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
oldNick, newNick string,
|
||||||
|
) {
|
||||||
|
channels, err := s.DB.GetSessionChannels(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
||||||
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
|
dbID, _, insErr := s.DB.InsertMessage(
|
||||||
|
ctx, irc.CmdNick, oldNick, "",
|
||||||
|
nil, body, nil,
|
||||||
|
)
|
||||||
|
if insErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the user themselves (for multi-client sync).
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
||||||
|
s.Broker.Notify(sessionID)
|
||||||
|
notified[sessionID] = true
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||||
|
ctx, ch.ID,
|
||||||
|
)
|
||||||
|
if memErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mid := range memberIDs {
|
||||||
|
if notified[mid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
notified[mid] = true
|
||||||
|
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
||||||
|
s.Broker.Notify(mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user