Compare commits
1
Commits
next
...
63d87a372e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d87a372e |
@@ -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
|
to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on
|
||||||
the IRC listener.
|
the IRC listener.
|
||||||
|
|
||||||
| Mode | Name | Meaning | Status |
|
| Mode | Name | Meaning | Status |
|
||||||
| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
| `+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 |
|
| `+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**; the IRC listener sends no key, so its users cannot join a channel that has one |
|
| `+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** |
|
| `+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** |
|
| `+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 |
|
| `+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 |
|
| `+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** |
|
| `+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 |
|
| `+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):**
|
**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
|
These ban all users from example.com, remove that ban, and list all bans
|
||||||
(RPL_BANLIST 367, RPL_ENDOFBANLIST 368).
|
(RPL_BANLIST 367, RPL_ENDOFBANLIST 368).
|
||||||
|
|
||||||
**Invite-only (+i):** When set, users must be invited before joining. Over the
|
**Invite-only (+i):** When set, users must be invited before joining. `INVITE`
|
||||||
HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can
|
records an invite that is consumed on JOIN; any member can invite, and on a `+i`
|
||||||
invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener
|
channel only an operator can.
|
||||||
tells the user about the invite but records none.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"command": "MODE", "to": "#channel", "body": ["+i"]}
|
{"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
|
**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
|
```json
|
||||||
{"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]}
|
{"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"]}
|
{"command": "MODE", "to": "#channel", "body": ["-l"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Secret (+s):** The HTTP API's `LIST` hides the channel from non-members, and
|
**Secret (+s):** `LIST` hides the channel from non-members, and `WHOIS` leaves
|
||||||
its `WHOIS` leaves it out of the channel list when the querier is not in the
|
it out of the channel list when the querier is not in the same channel.
|
||||||
same channel.
|
|
||||||
|
|
||||||
**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not
|
**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
|
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
|
`KICK #channel nick [:reason]`. The kicked user and all channel members receive
|
||||||
the KICK message.
|
the KICK message.
|
||||||
|
|
||||||
**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check
|
**NOTICE:** A NOTICE gets no hashcash check on `+H` channels, but it gets the
|
||||||
on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401,
|
same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it
|
||||||
403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a
|
gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is
|
||||||
user who is away gets RPL_AWAY there.
|
away, as it does for PRIVMSG.
|
||||||
|
|
||||||
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with
|
**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
|
`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)
|
### Per-Channel Hashcash (Anti-Spam)
|
||||||
|
|
||||||
Channels can require hashcash proof-of-work for every `PRIVMSG` sent over the
|
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||||||
HTTP API; the IRC listener does not check it. This is an anti-spam mechanism:
|
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||||||
channel operators set a difficulty level, and clients must compute a
|
compute a proof-of-work stamp bound to the specific channel and message before
|
||||||
proof-of-work stamp bound to the specific channel and message before sending.
|
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:**
|
**Setting the requirement:**
|
||||||
|
|
||||||
|
|||||||
@@ -1482,57 +1482,6 @@ type ChannelInfoFull struct {
|
|||||||
MemberCount int64 `json:"memberCount"`
|
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
|
// GetChannelCreatedAt returns the creation time of a
|
||||||
// channel.
|
// channel.
|
||||||
func (database *Database) GetChannelCreatedAt(
|
func (database *Database) GetChannelCreatedAt(
|
||||||
|
|||||||
+6
-291
@@ -15,16 +15,10 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
errHashcashRequired = errors.New("hashcash required")
|
|
||||||
errHashcashReused = errors.New("hashcash reused")
|
|
||||||
)
|
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||||
)
|
)
|
||||||
@@ -1231,23 +1225,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
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.
|
// Delegate validation + fan-out to service layer.
|
||||||
dbID, uuid, err := hdlr.svc.SendChannelMessage(
|
dbID, uuid, err := hdlr.svc.SendChannelMessage(
|
||||||
ctx, sessionID, nick,
|
ctx, sessionID, nick,
|
||||||
@@ -1272,160 +1249,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
http.StatusOK)
|
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(
|
func (hdlr *Handlers) handleDirectMsg(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -2914,123 +2737,15 @@ func (hdlr *Handlers) handleInvite(
|
|||||||
channel = "#" + channel
|
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()
|
ctx := request.Context()
|
||||||
|
|
||||||
chID, err := hdlr.params.Database.GetChannelByName(
|
targetSID, err := hdlr.svc.InviteUser(
|
||||||
ctx, channel,
|
ctx, sessionID, nick, targetNick, channel,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if hdlr.handleServiceError(
|
||||||
hdlr.respondIRCError(
|
writer, request,
|
||||||
writer, request, clientID, sessionID,
|
clientID, sessionID, nick, err,
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,16 +55,15 @@ const spentHashcashTTL = 365 * 24 * time.Hour
|
|||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
svc *service.Service
|
svc *service.Service
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
channelHashcash *hashcash.ChannelValidator
|
loginLimiter *ratelimit.Limiter
|
||||||
loginLimiter *ratelimit.Limiter
|
stats *stats.Tracker
|
||||||
stats *stats.Tracker
|
cancelCleanup context.CancelFunc
|
||||||
cancelCleanup context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -88,15 +87,14 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: params.Broker,
|
broker: params.Broker,
|
||||||
svc: params.Service,
|
svc: params.Service,
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
channelHashcash: hashcash.NewChannelValidator(),
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
stats: params.Stats,
|
||||||
stats: params.Stats,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ func (c *Conn) handlePrivmsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleJoin joins one or more channels via the shared
|
// 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(
|
func (c *Conn) handleJoin(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg *Message,
|
msg *Message,
|
||||||
@@ -164,14 +166,24 @@ func (c *Conn) handleJoin(
|
|||||||
return
|
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)
|
chanName = strings.TrimSpace(chanName)
|
||||||
|
|
||||||
if !strings.HasPrefix(chanName, "#") {
|
if !strings.HasPrefix(chanName, "#") {
|
||||||
chanName = "#" + 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.
|
// and delivers topic/names on the wire.
|
||||||
func (c *Conn) joinChannel(
|
func (c *Conn) joinChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channel string,
|
channel, key string,
|
||||||
) {
|
) {
|
||||||
result, err := c.svc.JoinChannel(
|
result, err := c.svc.JoinChannel(
|
||||||
ctx, c.sessionID, c.nick, channel, "",
|
ctx, c.sessionID, c.nick, channel, key,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -763,10 +775,11 @@ func (c *Conn) handleNames(
|
|||||||
c.deliverNames(ctx, channel, chID)
|
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) {
|
func (c *Conn) handleList(ctx context.Context) {
|
||||||
channels, err := c.database.ListAllChannelsWithCounts(
|
channels, err := c.database.ListAllChannelsWithCountsFiltered(
|
||||||
ctx,
|
ctx, c.sessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendNumeric(
|
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(
|
func (c *Conn) whoisChannels(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
target string,
|
target string,
|
||||||
targetID int64,
|
targetID int64,
|
||||||
) {
|
) {
|
||||||
userChannels, _ := c.database.GetSessionChannels(
|
userChannels, _ := c.database.GetSessionChannelsFiltered(
|
||||||
ctx, targetID,
|
ctx, targetID, c.sessionID,
|
||||||
)
|
)
|
||||||
if len(userChannels) == 0 {
|
if len(userChannels) == 0 {
|
||||||
return
|
return
|
||||||
@@ -1230,36 +1244,11 @@ func (c *Conn) handleInvite(
|
|||||||
targetNick := msg.Params[0]
|
targetNick := msg.Params[0]
|
||||||
channel := msg.Params[1]
|
channel := msg.Params[1]
|
||||||
|
|
||||||
chID, err := c.database.GetChannelByName(ctx, channel)
|
targetID, err := c.svc.InviteUser(
|
||||||
if err != nil {
|
ctx, c.sessionID, c.nick, targetNick, channel,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendNumeric(
|
c.sendIRCError(err)
|
||||||
irc.ErrNoSuchNick,
|
|
||||||
targetNick, "No such nick",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1239,6 +1239,216 @@ func TestIntegrationModeModerated(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIntegrationModeKey checks that JOIN takes the key of
|
||||||
|
// a +k channel as its second parameter, and that with
|
||||||
|
// several channels each key goes to the channel in the
|
||||||
|
// same position.
|
||||||
|
func TestIntegrationModeKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
alice.joinAndDrain("#keyed")
|
||||||
|
alice.joinAndDrain("#vault")
|
||||||
|
|
||||||
|
// The IRC listener cannot set +k, so the test sets the
|
||||||
|
// keys directly.
|
||||||
|
for channel, key := range map[string]string{
|
||||||
|
"#keyed": "sesame",
|
||||||
|
"#vault": "barley",
|
||||||
|
} {
|
||||||
|
chID, err := env.database.GetChannelByName(
|
||||||
|
t.Context(), channel,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get channel %s: %v", channel, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.database.SetChannelKey(t.Context(), chID, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set key of %s: %v", channel, 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
carol := env.dial(t)
|
||||||
|
carol.register("carol")
|
||||||
|
|
||||||
|
lines = carol.sendAndExpect(
|
||||||
|
"JOIN #keyed,#vault sesame,barley", " 366 carol #vault ",
|
||||||
|
)
|
||||||
|
assertContains(t, lines, "JOIN #keyed", "carol joins #keyed")
|
||||||
|
assertContains(t, lines, "JOIN #vault", "carol joins #vault")
|
||||||
|
assertNotContains(t, lines, " 475 ", "carol's JOIN")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationModeInviteOnlyNeedsOperator checks that on
|
||||||
|
// a +i channel an INVITE from a member who is not a channel
|
||||||
|
// operator gets 482 and does not let the invited user in.
|
||||||
|
func TestIntegrationModeInviteOnlyNeedsOperator(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
alice.joinAndDrain("#invonly")
|
||||||
|
|
||||||
|
// Bob joins before +i is set, so he is a member but
|
||||||
|
// not an operator.
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
bob.joinAndDrain("#invonly")
|
||||||
|
|
||||||
|
alice.sendAndExpect("MODE #invonly +i", "+i")
|
||||||
|
|
||||||
|
carol := env.dial(t)
|
||||||
|
carol.register("carol")
|
||||||
|
|
||||||
|
lines := bob.sendAndExpect("INVITE carol #invonly", " 482 ")
|
||||||
|
assertContains(
|
||||||
|
t, lines, " 482 bob #invonly ", "bob's INVITE refused",
|
||||||
|
)
|
||||||
|
|
||||||
|
carol.sendAndExpect("JOIN #invonly", " 473 ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationInviteUserOnChannel checks that inviting a
|
||||||
|
// user who is already on the channel gets 443.
|
||||||
|
func TestIntegrationInviteUserOnChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
alice.joinAndDrain("#room")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
bob.joinAndDrain("#room")
|
||||||
|
|
||||||
|
lines := alice.sendAndExpect("INVITE bob #room", " 443 ")
|
||||||
|
assertContains(
|
||||||
|
t, lines, " 443 alice bob #room ", "alice's INVITE refused",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// TestIntegrationThirdClientObserver verifies that a third
|
||||||
// client observing the same channel receives messages from
|
// client observing the same channel receives messages from
|
||||||
// the other two.
|
// the other two.
|
||||||
|
|||||||
+205
-23
@@ -21,6 +21,7 @@ import (
|
|||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"sneak.berlin/go/neoirc/internal/globals"
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
"sneak.berlin/go/neoirc/internal/logger"
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,7 @@ const (
|
|||||||
msgNoSuchChannel = "No such channel"
|
msgNoSuchChannel = "No such channel"
|
||||||
msgNoSuchNick = "No such nick/channel"
|
msgNoSuchNick = "No such nick/channel"
|
||||||
msgNotChannelOp = "You're not channel operator"
|
msgNotChannelOp = "You're not channel operator"
|
||||||
|
msgNotOnChannel = "You're not on that channel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxUserhostNicks is how many nicks one USERHOST answers
|
// maxUserhostNicks is how many nicks one USERHOST answers
|
||||||
@@ -63,11 +65,12 @@ type WireConn interface {
|
|||||||
|
|
||||||
// Service provides shared business logic for IRC commands.
|
// Service provides shared business logic for IRC commands.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
config *config.Config
|
config *config.Config
|
||||||
globals *globals.Globals
|
globals *globals.Globals
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
channelHashcash *hashcash.ChannelValidator
|
||||||
|
|
||||||
wireMu sync.Mutex
|
wireMu sync.Mutex
|
||||||
wireConns map[int64]WireConn
|
wireConns map[int64]WireConn
|
||||||
@@ -76,12 +79,13 @@ type Service struct {
|
|||||||
// New creates a new Service.
|
// New creates a new Service.
|
||||||
func New(params Params) *Service {
|
func New(params Params) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
broker: params.Broker,
|
broker: params.Broker,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
globals: params.Globals,
|
globals: params.Globals,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
wireConns: make(map[int64]WireConn),
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +99,13 @@ func NewTestService(
|
|||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: database,
|
db: database,
|
||||||
broker: brk,
|
broker: brk,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
globals: globs,
|
globals: globs,
|
||||||
log: log,
|
log: log,
|
||||||
wireConns: make(map[int64]WireConn),
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,10 +371,11 @@ func excludeSession(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendChannelMessage validates membership and moderation,
|
// SendChannelMessage validates the hashcash stamp of a
|
||||||
// then fans out a message to all channel members except
|
// PRIVMSG to a +H channel, membership and moderation, then
|
||||||
// the sender. Returns the database row ID, message UUID,
|
// fans out a message to all channel members except the
|
||||||
// and any error. The dbID lets callers enqueue the same
|
// 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
|
// message to the sender when echo is needed (HTTP
|
||||||
// transport).
|
// transport).
|
||||||
func (s *Service) SendChannelMessage(
|
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(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
@@ -568,7 +583,7 @@ func (s *Service) PartChannel(
|
|||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNotOnChannel,
|
irc.ErrNotOnChannel,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"You're not on that channel",
|
msgNotOnChannel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +625,7 @@ func (s *Service) SetTopic(
|
|||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNotOnChannel,
|
irc.ErrNotOnChannel,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"You're not on that channel",
|
msgNotOnChannel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,6 +728,78 @@ func (s *Service) KickUser(
|
|||||||
return nil
|
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
|
// ChangeNick changes a user's nickname and broadcasts the
|
||||||
// change to all users sharing channels.
|
// change to all users sharing channels.
|
||||||
func (s *Service) ChangeNick(
|
func (s *Service) ChangeNick(
|
||||||
@@ -1169,6 +1256,71 @@ func (s *Service) requireOper(
|
|||||||
return nil
|
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
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
// change.
|
// change.
|
||||||
func (s *Service) broadcastNickChange(
|
func (s *Service) broadcastNickChange(
|
||||||
@@ -1277,3 +1429,33 @@ func checkJoinRestrictions(
|
|||||||
|
|
||||||
return nil
|
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