Enforce channel keys, invites, +s and +H on the IRC listener (closes #122)
check / check (push) Waiting to run
check / check (push) Waiting to run
The IRC listener's JOIN now passes the channel key on, INVITE records the invite, LIST and WHOIS hide +s channels from non-members with the queries the HTTP API uses, and PRIVMSG to a +H channel gets 404. The INVITE checks and the channel hashcash check move from the HTTP handlers into the service, so both transports run the same code. An IRC client cannot attach a stamp, so its PRIVMSG to a +H channel always fails that check. The unfiltered channel list query, now unused, is removed. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #139.
This commit is contained in:
@@ -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,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
|
||||
// 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