Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03deb8d9aa |
@@ -1277,17 +1277,17 @@ Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how
|
||||
to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on
|
||||
the IRC listener.
|
||||
|
||||
| Mode | Name | Meaning | Status |
|
||||
| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
||||
| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced**; only an invite made over the HTTP API lets a user in |
|
||||
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced**; the IRC listener sends no key, so its users cannot join a channel that has one |
|
||||
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
||||
| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set |
|
||||
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS` over the HTTP API; not by `GET /api/v1/channels` or the IRC listener |
|
||||
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
|
||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** over the HTTP API only |
|
||||
| Mode | Name | Meaning | Status |
|
||||
| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
||||
| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced** |
|
||||
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** |
|
||||
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
||||
| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set |
|
||||
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS`; not by `GET /api/v1/channels` |
|
||||
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
|
||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced**; the IRC listener refuses every PRIVMSG to the channel, as it cannot carry a stamp |
|
||||
|
||||
**User channel modes (set per-user per-channel):**
|
||||
|
||||
@@ -1312,10 +1312,9 @@ bans. Bans prevent both joining and sending messages.
|
||||
These ban all users from example.com, remove that ban, and list all bans
|
||||
(RPL_BANLIST 367, RPL_ENDOFBANLIST 368).
|
||||
|
||||
**Invite-only (+i):** When set, users must be invited before joining. Over the
|
||||
HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can
|
||||
invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener
|
||||
tells the user about the invite but records none.
|
||||
**Invite-only (+i):** When set, users must be invited before joining. `INVITE`
|
||||
records an invite that is consumed on JOIN; any member can invite, and on a `+i`
|
||||
channel only an operator can.
|
||||
|
||||
```json
|
||||
{"command": "MODE", "to": "#channel", "body": ["+i"]}
|
||||
@@ -1323,7 +1322,9 @@ tells the user about the invite but records none.
|
||||
```
|
||||
|
||||
**Channel key (+k):** Requires a password to join the channel. Over the HTTP API
|
||||
the key is `body[0]` of the `JOIN`.
|
||||
the key is `body[0]` of the `JOIN`. On the IRC listener it follows the channel
|
||||
name, as in `JOIN #channel secretpass`; `JOIN #a,#b keya,keyb` gives each
|
||||
channel its own key.
|
||||
|
||||
```json
|
||||
{"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]}
|
||||
@@ -1338,9 +1339,8 @@ the key is `body[0]` of the `JOIN`.
|
||||
{"command": "MODE", "to": "#channel", "body": ["-l"]}
|
||||
```
|
||||
|
||||
**Secret (+s):** The HTTP API's `LIST` hides the channel from non-members, and
|
||||
its `WHOIS` leaves it out of the channel list when the querier is not in the
|
||||
same channel.
|
||||
**Secret (+s):** `LIST` hides the channel from non-members, and `WHOIS` leaves
|
||||
it out of the channel list when the querier is not in the same channel.
|
||||
|
||||
**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not
|
||||
invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong
|
||||
@@ -1351,10 +1351,10 @@ key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL
|
||||
`KICK #channel nick [:reason]`. The kicked user and all channel members receive
|
||||
the KICK message.
|
||||
|
||||
**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check
|
||||
on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401,
|
||||
403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a
|
||||
user who is away gets RPL_AWAY there.
|
||||
**NOTICE:** A NOTICE gets no hashcash check on `+H` channels, but it gets the
|
||||
same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it
|
||||
gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is
|
||||
away, as it does for PRIVMSG.
|
||||
|
||||
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with
|
||||
`CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC
|
||||
@@ -1362,10 +1362,11 @@ listener.
|
||||
|
||||
### Per-Channel Hashcash (Anti-Spam)
|
||||
|
||||
Channels can require hashcash proof-of-work for every `PRIVMSG` sent over the
|
||||
HTTP API; the IRC listener does not check it. This is an anti-spam mechanism:
|
||||
channel operators set a difficulty level, and clients must compute a
|
||||
proof-of-work stamp bound to the specific channel and message before sending.
|
||||
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||||
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||||
compute a proof-of-work stamp bound to the specific channel and message before
|
||||
sending. An IRC listener client has no way to attach a stamp, so its `PRIVMSG`
|
||||
to such a channel always gets ERR_CANNOTSENDTOCHAN (404).
|
||||
|
||||
**Setting the requirement:**
|
||||
|
||||
|
||||
@@ -1482,57 +1482,6 @@ type ChannelInfoFull struct {
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
}
|
||||
|
||||
// ListAllChannelsWithCounts returns every channel
|
||||
// with its member count.
|
||||
func (database *Database) ListAllChannelsWithCounts(
|
||||
ctx context.Context,
|
||||
) ([]ChannelInfoFull, error) {
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT c.id, c.name, c.topic,
|
||||
COUNT(cm.session_id) AS member_count
|
||||
FROM channels c
|
||||
LEFT JOIN channel_members cm
|
||||
ON cm.channel_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"list channels with counts: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []ChannelInfoFull
|
||||
|
||||
for rows.Next() {
|
||||
var chanInfo ChannelInfoFull
|
||||
|
||||
err = rows.Scan(
|
||||
&chanInfo.ID, &chanInfo.Name,
|
||||
&chanInfo.Topic, &chanInfo.MemberCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"scan channel full: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
out = append(out, chanInfo)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
out = []ChannelInfoFull{}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetChannelCreatedAt returns the creation time of a
|
||||
// channel.
|
||||
func (database *Database) GetChannelCreatedAt(
|
||||
|
||||
+6
-291
@@ -15,16 +15,10 @@ import (
|
||||
|
||||
"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 (
|
||||
errHashcashRequired = errors.New("hashcash required")
|
||||
errHashcashReused = errors.New("hashcash reused")
|
||||
)
|
||||
|
||||
var validNickRe = regexp.MustCompile(
|
||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||
)
|
||||
@@ -1231,23 +1225,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Hashcash validation is HTTP-specific; needs chID.
|
||||
isNotice := command == irc.CmdNotice
|
||||
|
||||
if !isNotice {
|
||||
chID, chErr := hdlr.params.Database.
|
||||
GetChannelByName(ctx, target)
|
||||
if chErr == nil {
|
||||
hashcashErr := hdlr.validateChannelHashcash(
|
||||
request, clientID, sessionID,
|
||||
writer, nick, target, body, meta, chID,
|
||||
)
|
||||
if hashcashErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate validation + fan-out to service layer.
|
||||
dbID, uuid, err := hdlr.svc.SendChannelMessage(
|
||||
ctx, sessionID, nick,
|
||||
@@ -1272,160 +1249,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// validateChannelHashcash checks whether the channel
|
||||
// requires hashcash proof-of-work for messages and
|
||||
// validates the stamp from the message meta field.
|
||||
// Returns nil on success or if the channel has no
|
||||
// hashcash requirement. On failure, it sends the
|
||||
// appropriate IRC error and returns a non-nil error.
|
||||
func (hdlr *Handlers) validateChannelHashcash(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
writer http.ResponseWriter,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
chID int64,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
|
||||
ctx, chID,
|
||||
)
|
||||
if bitsErr != nil {
|
||||
hdlr.log.Error(
|
||||
"get channel hashcash bits", "error", bitsErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
|
||||
}
|
||||
|
||||
if bits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stamp := hdlr.extractHashcashFromMeta(meta)
|
||||
if stamp == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Channel requires hashcash proof-of-work",
|
||||
)
|
||||
|
||||
return errHashcashRequired
|
||||
}
|
||||
|
||||
return hdlr.verifyChannelStamp(
|
||||
request, writer,
|
||||
clientID, sessionID,
|
||||
nick, target, body, stamp, bits,
|
||||
)
|
||||
}
|
||||
|
||||
// verifyChannelStamp validates a channel hashcash stamp
|
||||
// and checks for replay attacks.
|
||||
func (hdlr *Handlers) verifyChannelStamp(
|
||||
request *http.Request,
|
||||
writer http.ResponseWriter,
|
||||
clientID, sessionID int64,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
stamp string,
|
||||
bits int,
|
||||
) error {
|
||||
ctx := request.Context()
|
||||
bodyHashStr := hashcash.BodyHash(body)
|
||||
|
||||
valErr := hdlr.channelHashcash.ValidateStamp(
|
||||
stamp, bits, target, bodyHashStr,
|
||||
)
|
||||
if valErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Invalid hashcash: "+valErr.Error(),
|
||||
)
|
||||
|
||||
return fmt.Errorf("channel hashcash: %w", valErr)
|
||||
}
|
||||
|
||||
stampKey := hashcash.StampHash(stamp)
|
||||
|
||||
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if spentErr != nil {
|
||||
hdlr.log.Error(
|
||||
"check spent hashcash", "error", spentErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return fmt.Errorf("check spent hashcash: %w", spentErr)
|
||||
}
|
||||
|
||||
if spent {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick, []string{target},
|
||||
"Hashcash stamp already used",
|
||||
)
|
||||
|
||||
return errHashcashReused
|
||||
}
|
||||
|
||||
recordErr := hdlr.params.Database.RecordSpentHashcash(
|
||||
ctx, stampKey,
|
||||
)
|
||||
if recordErr != nil {
|
||||
hdlr.log.Error(
|
||||
"record spent hashcash", "error", recordErr,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHashcashFromMeta parses the meta JSON and
|
||||
// returns the hashcash stamp string, or empty string
|
||||
// if not present.
|
||||
func (hdlr *Handlers) extractHashcashFromMeta(
|
||||
meta json.RawMessage,
|
||||
) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var metaMap map[string]json.RawMessage
|
||||
|
||||
err := json.Unmarshal(meta, &metaMap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw, ok := metaMap["hashcash"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var stamp string
|
||||
|
||||
err = json.Unmarshal(raw, &stamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stamp
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handleDirectMsg(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -2914,123 +2737,15 @@ func (hdlr *Handlers) handleInvite(
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
chID, targetSID, ok := hdlr.validateInvite(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
targetNick, channel,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.executeInvite(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
targetNick, channel, chID, targetSID,
|
||||
)
|
||||
}
|
||||
|
||||
// validateInvite checks channel, membership, permissions,
|
||||
// and target for an INVITE command.
|
||||
func (hdlr *Handlers) validateInvite(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, targetNick, channel string,
|
||||
) (int64, int64, bool) {
|
||||
ctx := request.Context()
|
||||
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
ctx, channel,
|
||||
targetSID, err := hdlr.svc.InviteUser(
|
||||
ctx, sessionID, nick, targetNick, channel,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick,
|
||||
[]string{channel}, "No such channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
isMember, memErr := hdlr.params.Database.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if memErr != nil || !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNotOnChannel, nick,
|
||||
[]string{channel},
|
||||
"You're not on that channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
isInviteOnly, ioErr := hdlr.params.Database.
|
||||
IsChannelInviteOnly(ctx, chID)
|
||||
if ioErr == nil && isInviteOnly {
|
||||
if !hdlr.requireChannelOp(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
) {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
|
||||
targetSID, nickErr := hdlr.params.Database.
|
||||
GetSessionByNick(ctx, targetNick)
|
||||
if nickErr != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchNick, nick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
alreadyIn, aiErr := hdlr.params.Database.
|
||||
IsChannelMember(ctx, chID, targetSID)
|
||||
if aiErr == nil && alreadyIn {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUserOnChannel, nick,
|
||||
[]string{targetNick, channel},
|
||||
"is already on channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return chID, targetSID, true
|
||||
}
|
||||
|
||||
// executeInvite records the invite and sends
|
||||
// notifications.
|
||||
func (hdlr *Handlers) executeInvite(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, targetNick, channel string,
|
||||
chID, targetSID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
invErr := hdlr.params.Database.AddChannelInvite(
|
||||
ctx, chID, targetSID, nick,
|
||||
)
|
||||
if invErr != nil {
|
||||
hdlr.log.Error(
|
||||
"add invite failed", "error", invErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
if hdlr.handleServiceError(
|
||||
writer, request,
|
||||
clientID, sessionID, nick, err,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -55,16 +55,15 @@ const spentHashcashTTL = 365 * 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
svc *service.Service
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
loginLimiter *ratelimit.Limiter
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
svc *service.Service
|
||||
hashcashVal *hashcash.Validator
|
||||
loginLimiter *ratelimit.Limiter
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
@@ -88,15 +87,14 @@ func New(
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: params.Broker,
|
||||
svc: params.Service,
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||
stats: params.Stats,
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: params.Broker,
|
||||
svc: params.Service,
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
|
||||
@@ -150,7 +150,9 @@ func (c *Conn) handlePrivmsg(
|
||||
}
|
||||
|
||||
// handleJoin joins one or more channels via the shared
|
||||
// service layer.
|
||||
// service layer. The optional second parameter holds the
|
||||
// channel keys, comma-separated in the order of the
|
||||
// channels.
|
||||
func (c *Conn) handleJoin(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
@@ -164,14 +166,24 @@ func (c *Conn) handleJoin(
|
||||
return
|
||||
}
|
||||
|
||||
for chanName := range strings.SplitSeq(msg.Params[0], ",") {
|
||||
var keys []string
|
||||
if len(msg.Params) > 1 {
|
||||
keys = strings.Split(msg.Params[1], ",")
|
||||
}
|
||||
|
||||
for idx, chanName := range strings.Split(msg.Params[0], ",") {
|
||||
chanName = strings.TrimSpace(chanName)
|
||||
|
||||
if !strings.HasPrefix(chanName, "#") {
|
||||
chanName = "#" + chanName
|
||||
}
|
||||
|
||||
c.joinChannel(ctx, chanName)
|
||||
key := ""
|
||||
if idx < len(keys) {
|
||||
key = keys[idx]
|
||||
}
|
||||
|
||||
c.joinChannel(ctx, chanName, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +191,10 @@ func (c *Conn) handleJoin(
|
||||
// and delivers topic/names on the wire.
|
||||
func (c *Conn) joinChannel(
|
||||
ctx context.Context,
|
||||
channel string,
|
||||
channel, key string,
|
||||
) {
|
||||
result, err := c.svc.JoinChannel(
|
||||
ctx, c.sessionID, c.nick, channel, "",
|
||||
ctx, c.sessionID, c.nick, channel, key,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
@@ -763,10 +775,11 @@ func (c *Conn) handleNames(
|
||||
c.deliverNames(ctx, channel, chID)
|
||||
}
|
||||
|
||||
// handleList sends the channel list.
|
||||
// handleList sends the channel list, leaving out +s
|
||||
// channels the user is not on.
|
||||
func (c *Conn) handleList(ctx context.Context) {
|
||||
channels, err := c.database.ListAllChannelsWithCounts(
|
||||
ctx,
|
||||
channels, err := c.database.ListAllChannelsWithCountsFiltered(
|
||||
ctx, c.sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendNumeric(
|
||||
@@ -886,14 +899,15 @@ func (c *Conn) whoisOper(
|
||||
}
|
||||
}
|
||||
|
||||
// whoisChannels sends 319 RPL_WHOISCHANNELS.
|
||||
// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out
|
||||
// +s channels the querier is not on.
|
||||
func (c *Conn) whoisChannels(
|
||||
ctx context.Context,
|
||||
target string,
|
||||
targetID int64,
|
||||
) {
|
||||
userChannels, _ := c.database.GetSessionChannels(
|
||||
ctx, targetID,
|
||||
userChannels, _ := c.database.GetSessionChannelsFiltered(
|
||||
ctx, targetID, c.sessionID,
|
||||
)
|
||||
if len(userChannels) == 0 {
|
||||
return
|
||||
@@ -1230,36 +1244,11 @@ func (c *Conn) handleInvite(
|
||||
targetNick := msg.Params[0]
|
||||
channel := msg.Params[1]
|
||||
|
||||
chID, err := c.database.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoSuchChannel,
|
||||
channel, "No such channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
isMember, _ := c.database.IsChannelMember(
|
||||
ctx, chID, c.sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
c.sendNumeric(
|
||||
irc.ErrNotOnChannel,
|
||||
channel, "You're not on that channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetID, err := c.database.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
targetID, err := c.svc.InviteUser(
|
||||
ctx, c.sessionID, c.nick, targetNick, channel,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoSuchNick,
|
||||
targetNick, "No such nick",
|
||||
)
|
||||
c.sendIRCError(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1239,6 +1239,146 @@ func TestIntegrationModeModerated(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationModeKey checks that JOIN takes the key of
|
||||
// a +k channel as its second parameter.
|
||||
func TestIntegrationModeKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
alice.joinAndDrain("#keyed")
|
||||
|
||||
// The IRC listener cannot set +k, so the test sets the
|
||||
// key directly.
|
||||
chID, err := env.database.GetChannelByName(
|
||||
t.Context(), "#keyed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel: %v", err)
|
||||
}
|
||||
|
||||
err = env.database.SetChannelKey(t.Context(), chID, "sesame")
|
||||
if err != nil {
|
||||
t.Fatalf("set channel key: %v", err)
|
||||
}
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
bob.sendAndExpect("JOIN #keyed", " 475 ")
|
||||
bob.sendAndExpect("JOIN #keyed wrong", " 475 ")
|
||||
|
||||
lines := bob.sendAndExpect("JOIN #keyed sesame", " 366 ")
|
||||
assertContains(
|
||||
t, lines, "JOIN #keyed", "bob joins with the key",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationModeInviteOnly checks that INVITE lets a
|
||||
// user into a +i channel.
|
||||
func TestIntegrationModeInviteOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
alice.joinAndDrain("#invonly")
|
||||
alice.sendAndExpect("MODE #invonly +i", "+i")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
bob.sendAndExpect("JOIN #invonly", " 473 ")
|
||||
|
||||
alice.sendAndExpect("INVITE bob #invonly", " 341 ")
|
||||
|
||||
lines := bob.sendAndExpect("JOIN #invonly", " 366 ")
|
||||
assertContains(
|
||||
t, lines, "JOIN #invonly", "bob joins once invited",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationModeSecretList checks that LIST shows a +s
|
||||
// channel only to its members.
|
||||
func TestIntegrationModeSecretList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
alice.joinAndDrain("#hidden")
|
||||
alice.joinAndDrain("#visible")
|
||||
alice.sendAndExpect("MODE #hidden +s", "+s")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
bobList := bob.sendAndExpect("LIST", " 323 ")
|
||||
assertContains(
|
||||
t, bobList, " 322 bob #visible ", "bob's LIST",
|
||||
)
|
||||
assertNotContains(t, bobList, "#hidden", "bob's LIST")
|
||||
|
||||
aliceList := alice.sendAndExpect("LIST", " 323 ")
|
||||
assertContains(
|
||||
t, aliceList, " 322 alice #hidden ", "alice's LIST",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationModeSecretWhois checks that WHOIS names a
|
||||
// +s channel only to users on it.
|
||||
func TestIntegrationModeSecretWhois(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
alice.joinAndDrain("#hidden")
|
||||
alice.joinAndDrain("#visible")
|
||||
alice.sendAndExpect("MODE #hidden +s", "+s")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
bobWhois := bob.sendAndExpect("WHOIS alice", " 318 ")
|
||||
assertContains(t, bobWhois, "@#visible", "bob's WHOIS")
|
||||
assertNotContains(t, bobWhois, "#hidden", "bob's WHOIS")
|
||||
|
||||
carol := env.dial(t)
|
||||
carol.register("carol")
|
||||
carol.joinAndDrain("#hidden")
|
||||
|
||||
carolWhois := carol.sendAndExpect("WHOIS alice", " 318 ")
|
||||
assertContains(t, carolWhois, "@#hidden", "carol's WHOIS")
|
||||
}
|
||||
|
||||
// TestIntegrationModeHashcash checks that PRIVMSG to a +H
|
||||
// channel is refused, since an IRC client cannot attach a
|
||||
// hashcash stamp.
|
||||
func TestIntegrationModeHashcash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
alice.joinAndDrain("#stamped")
|
||||
alice.sendAndExpect("MODE #stamped +H 2", "+H")
|
||||
|
||||
lines := alice.sendAndExpect(
|
||||
"PRIVMSG #stamped :hello", " 404 ",
|
||||
)
|
||||
assertContains(
|
||||
t, lines, " 404 alice #stamped :Channel requires hashcash",
|
||||
"PRIVMSG refused",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationThirdClientObserver verifies that a third
|
||||
// client observing the same channel receives messages from
|
||||
// the other two.
|
||||
|
||||
+205
-23
@@ -21,6 +21,7 @@ import (
|
||||
"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/logger"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
@@ -30,6 +31,7 @@ const (
|
||||
msgNoSuchChannel = "No such channel"
|
||||
msgNoSuchNick = "No such nick/channel"
|
||||
msgNotChannelOp = "You're not channel operator"
|
||||
msgNotOnChannel = "You're not on that channel"
|
||||
)
|
||||
|
||||
// maxUserhostNicks is how many nicks one USERHOST answers
|
||||
@@ -63,11 +65,12 @@ type WireConn interface {
|
||||
|
||||
// Service provides shared business logic for IRC commands.
|
||||
type Service struct {
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
config *config.Config
|
||||
globals *globals.Globals
|
||||
log *slog.Logger
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
config *config.Config
|
||||
globals *globals.Globals
|
||||
log *slog.Logger
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
|
||||
wireMu sync.Mutex
|
||||
wireConns map[int64]WireConn
|
||||
@@ -76,12 +79,13 @@ type Service struct {
|
||||
// New creates a new Service.
|
||||
func New(params Params) *Service {
|
||||
return &Service{
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
globals: params.Globals,
|
||||
log: params.Logger.Get(),
|
||||
wireConns: make(map[int64]WireConn),
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
globals: params.Globals,
|
||||
log: params.Logger.Get(),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +99,13 @@ func NewTestService(
|
||||
log *slog.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
globals: globs,
|
||||
log: log,
|
||||
wireConns: make(map[int64]WireConn),
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
globals: globs,
|
||||
log: log,
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,10 +371,11 @@ func excludeSession(
|
||||
return out
|
||||
}
|
||||
|
||||
// SendChannelMessage validates membership and moderation,
|
||||
// then fans out a message to all channel members except
|
||||
// the sender. Returns the database row ID, message UUID,
|
||||
// and any error. The dbID lets callers enqueue the same
|
||||
// SendChannelMessage validates the hashcash stamp of a
|
||||
// PRIVMSG to a +H channel, membership and moderation, then
|
||||
// fans out a message to all channel members except the
|
||||
// sender. Returns the database row ID, message UUID, and
|
||||
// any error. The dbID lets callers enqueue the same
|
||||
// message to the sender when echo is needed (HTTP
|
||||
// transport).
|
||||
func (s *Service) SendChannelMessage(
|
||||
@@ -387,6 +393,15 @@ func (s *Service) SendChannelMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if command == irc.CmdPrivmsg {
|
||||
err = s.checkChannelHashcash(
|
||||
ctx, chID, channel, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
@@ -568,7 +583,7 @@ func (s *Service) PartChannel(
|
||||
return &IRCError{
|
||||
irc.ErrNotOnChannel,
|
||||
[]string{channel},
|
||||
"You're not on that channel",
|
||||
msgNotOnChannel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +625,7 @@ func (s *Service) SetTopic(
|
||||
return &IRCError{
|
||||
irc.ErrNotOnChannel,
|
||||
[]string{channel},
|
||||
"You're not on that channel",
|
||||
msgNotOnChannel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,6 +728,78 @@ func (s *Service) KickUser(
|
||||
return nil
|
||||
}
|
||||
|
||||
// InviteUser records an invite of targetNick to channel,
|
||||
// which JoinChannel consumes, and returns the target's
|
||||
// session ID. The inviter must be on the channel, and an
|
||||
// operator if it is +i; the target must exist and not be
|
||||
// on the channel already.
|
||||
func (s *Service) InviteUser(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, targetNick, channel string,
|
||||
) (int64, error) {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return 0, &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
msgNoSuchChannel,
|
||||
}
|
||||
}
|
||||
|
||||
isMember, memErr := s.db.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if memErr != nil || !isMember {
|
||||
return 0, &IRCError{
|
||||
irc.ErrNotOnChannel,
|
||||
[]string{channel},
|
||||
msgNotOnChannel,
|
||||
}
|
||||
}
|
||||
|
||||
isInviteOnly, ioErr := s.db.IsChannelInviteOnly(ctx, chID)
|
||||
if ioErr == nil && isInviteOnly {
|
||||
isOp, opErr := s.db.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if opErr != nil || !isOp {
|
||||
return 0, &IRCError{
|
||||
irc.ErrChanOpPrivsNeeded,
|
||||
[]string{channel},
|
||||
msgNotChannelOp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
|
||||
if err != nil {
|
||||
return 0, &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{targetNick},
|
||||
msgNoSuchNick,
|
||||
}
|
||||
}
|
||||
|
||||
alreadyIn, aiErr := s.db.IsChannelMember(
|
||||
ctx, chID, targetSID,
|
||||
)
|
||||
if aiErr == nil && alreadyIn {
|
||||
return 0, &IRCError{
|
||||
irc.ErrUserOnChannel,
|
||||
[]string{targetNick, channel},
|
||||
"is already on channel",
|
||||
}
|
||||
}
|
||||
|
||||
err = s.db.AddChannelInvite(ctx, chID, targetSID, nick)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("add invite: %w", err)
|
||||
}
|
||||
|
||||
return targetSID, nil
|
||||
}
|
||||
|
||||
// ChangeNick changes a user's nickname and broadcasts the
|
||||
// change to all users sharing channels.
|
||||
func (s *Service) ChangeNick(
|
||||
@@ -1169,6 +1256,71 @@ func (s *Service) requireOper(
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless
|
||||
// the channel has no +H, or meta carries a hashcash stamp
|
||||
// for this channel and body with enough bits that has not
|
||||
// been used before. A stamp that passes is recorded as
|
||||
// spent.
|
||||
func (s *Service) checkChannelHashcash(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
channel string,
|
||||
body, meta json.RawMessage,
|
||||
) error {
|
||||
bits, err := s.db.GetChannelHashcashBits(ctx, chID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("channel hashcash bits: %w", err)
|
||||
}
|
||||
|
||||
if bits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stamp := extractHashcashFromMeta(meta)
|
||||
if stamp == "" {
|
||||
return &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Channel requires hashcash proof-of-work",
|
||||
}
|
||||
}
|
||||
|
||||
err = s.channelHashcash.ValidateStamp(
|
||||
stamp, bits, channel, hashcash.BodyHash(body),
|
||||
)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Invalid hashcash: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
stampKey := hashcash.StampHash(stamp)
|
||||
|
||||
spent, err := s.db.IsHashcashSpent(ctx, stampKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check spent hashcash: %w", err)
|
||||
}
|
||||
|
||||
if spent {
|
||||
return &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Hashcash stamp already used",
|
||||
}
|
||||
}
|
||||
|
||||
err = s.db.RecordSpentHashcash(ctx, stampKey)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"record spent hashcash", "error", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
// change.
|
||||
func (s *Service) broadcastNickChange(
|
||||
@@ -1277,3 +1429,33 @@ func checkJoinRestrictions(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHashcashFromMeta parses the meta JSON and
|
||||
// returns the hashcash stamp string, or empty string
|
||||
// if not present.
|
||||
func extractHashcashFromMeta(meta json.RawMessage) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var metaMap map[string]json.RawMessage
|
||||
|
||||
err := json.Unmarshal(meta, &metaMap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw, ok := metaMap["hashcash"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var stamp string
|
||||
|
||||
err = json.Unmarshal(raw, &stamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stamp
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user