diff --git a/README.md b/README.md index fb97988..7fa7a0b 100644 --- a/README.md +++ b/README.md @@ -2307,8 +2307,8 @@ IRC_LISTEN_ADDR= | Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | | Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | | Messaging | `PRIVMSG`, `NOTICE` | -| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | -| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | +| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` | +| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | ### Protocol Details @@ -2820,6 +2820,10 @@ guess is borne by the server (bcrypt), not the client. login from additional devices via `POST /api/v1/login` - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API authentication +- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN + (256–259), INFO (371/374), TIME (391), KILL (oper-only forced + disconnect), WALLOPS (oper-only broadcast to +w users) +- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w` ### Future (1.0+) diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go index 68718a3..c49ae60 100644 --- a/internal/handlers/utility.go +++ b/internal/handlers/utility.go @@ -331,18 +331,12 @@ func (hdlr *Handlers) handleKill( } lines := bodyLines() - if len(lines) == 0 { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNeedMoreParams, nick, - []string{irc.CmdKill}, - "Not enough parameters", - ) - return + var targetNick string + if len(lines) > 0 { + targetNick = strings.TrimSpace(lines[0]) } - targetNick := strings.TrimSpace(lines[0]) if targetNick == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, @@ -383,8 +377,11 @@ func (hdlr *Handlers) handleKill( return } - hdlr.executeKillUser( - request, targetSID, targetNick, nick, reason, + quitReason := "Killed (" + nick + " (" + reason + "))" + + hdlr.svc.BroadcastQuit( + request.Context(), targetSID, + targetNick, quitReason, ) hdlr.respondJSON(writer, request, @@ -392,71 +389,6 @@ func (hdlr *Handlers) handleKill( http.StatusOK) } -// executeKillUser forcibly disconnects a user: broadcasts -// QUIT to their channels, parts all channels, and deletes -// the session. -func (hdlr *Handlers) executeKillUser( - request *http.Request, - targetSID int64, - targetNick, killerNick, reason string, -) { - ctx := request.Context() - - quitMsg := "Killed (" + killerNick + " (" + reason + "))" - - quitBody, err := json.Marshal([]string{quitMsg}) - if err != nil { - hdlr.log.Error( - "marshal kill quit body", "error", err, - ) - - return - } - - channels, _ := hdlr.params.Database. - GetSessionChannels(ctx, targetSID) - - notified := map[int64]bool{} - - var dbID int64 - - if len(channels) > 0 { - dbID, _, _ = hdlr.params.Database.InsertMessage( - ctx, irc.CmdQuit, targetNick, "", - nil, json.RawMessage(quitBody), nil, - ) - } - - for _, chanInfo := range channels { - memberIDs, _ := hdlr.params.Database. - GetChannelMemberIDs(ctx, chanInfo.ID) - - for _, mid := range memberIDs { - if mid != targetSID && !notified[mid] { - notified[mid] = true - - _ = hdlr.params.Database.EnqueueToSession( - ctx, mid, dbID, - ) - - hdlr.broker.Notify(mid) - } - } - - _ = hdlr.params.Database.PartChannel( - ctx, chanInfo.ID, targetSID, - ) - - _ = hdlr.params.Database.DeleteChannelIfEmpty( - ctx, chanInfo.ID, - ) - } - - _ = hdlr.params.Database.DeleteSession( - ctx, targetSID, - ) -} - // handleWallops handles the WALLOPS command. // Broadcasts a message to all users with +w usermode // (oper only). diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 01ced20..9b8bead 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -8,10 +8,29 @@ import ( "strings" "time" + "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/pkg/irc" ) +// versionString returns the server version for IRC +// responses, falling back to "neoirc-dev" when globals +// are not set (e.g. during tests). +func versionString() string { + name := globals.Appname + ver := globals.Version + + if name == "" { + name = "neoirc" + } + + if ver == "" { + ver = "dev" + } + + return name + "-" + ver +} + // sendIRCError maps a service.IRCError to an IRC numeric // reply on the wire. func (c *Conn) sendIRCError(err error) { @@ -1384,7 +1403,7 @@ func (c *Conn) handleUserhost( func (c *Conn) handleVersion(ctx context.Context) { _ = ctx - version := "neoirc-0.1" + version := versionString() c.sendNumeric( irc.RplVersion, @@ -1426,7 +1445,7 @@ func (c *Conn) handleInfo(ctx context.Context) { infoLines := []string{ "neoirc — IRC semantics over HTTP", - "Version: neoirc-0.1", + "Version: " + versionString(), "Written in Go", }