diff --git a/gencapdefs.py b/gencapdefs.py index 54d10718..0238ad45 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -238,7 +238,7 @@ CAPDEFS = [ standard="Soju/Goguma vendor", ), CapDef( - identifier="Metadata", + identifier="MetadataDraft2", name="draft/metadata-2", url="https://ircv3.net/specs/extensions/metadata", standard="draft IRCv3", @@ -249,8 +249,12 @@ CAPDEFS = [ url="https://github.com/ircv3/ircv3-specifications/pull/602", standard="proposed IRCv3", ), - - + CapDef( + identifier="Metadata", + name="draft/metadata-3", + url="https://github.com/ircv3/ircv3-specifications/pull/613", + standard="draft IRCv3", + ), ] def validate_defs(): diff --git a/irc/caps/constants.go b/irc/caps/constants.go index 1c08e22e..d8dbe580 100644 --- a/irc/caps/constants.go +++ b/irc/caps/constants.go @@ -70,6 +70,8 @@ const ( // authtoken draft: https://github.com/ircv3/ircv3-specifications/pull/602 AuthTokenBatchType = "draft/authtoken" AuthToken005 = "draft/AUTHTOKEN" + + MetadataSyncAllTarget = "*ALL" ) func init() { diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 14995794..8595d0dc 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 39 + numCapabs = 40 // length of the uint32 array that represents the bitset: bitsetLen = 2 ) @@ -69,8 +69,12 @@ const ( // https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md MessageRedaction Capability = iota - // Metadata is the draft IRCv3 capability named "draft/metadata-2": + // MetadataDraft2 is the draft IRCv3 capability named "draft/metadata-2": // https://ircv3.net/specs/extensions/metadata + MetadataDraft2 Capability = iota + + // Metadata is the draft IRCv3 capability named "draft/metadata-3": + // https://github.com/ircv3/ircv3-specifications/pull/613 Metadata Capability = iota // Multiline is the proposed IRCv3 capability named "draft/multiline": @@ -188,6 +192,7 @@ var ( "draft/languages", "draft/message-redaction", "draft/metadata-2", + "draft/metadata-3", "draft/multiline", "draft/persistence", "draft/pre-away", diff --git a/irc/client.go b/irc/client.go index a66798d0..00b156eb 100644 --- a/irc/client.go +++ b/irc/client.go @@ -822,6 +822,11 @@ func (client *Client) performReattach(session *Session) { rb.Send(true) } session.autoreplayMissedSince = time.Time{} + if session.capabilities.Has(caps.Metadata) { + rb := NewResponseBuffer(session) + syncAllMetadata(client.server, rb) + rb.Send(true) + } } func (client *Client) applyPreregMetadata(session *Session) { diff --git a/irc/config.go b/irc/config.go index d25a5da2..305a4238 100644 --- a/irc/config.go +++ b/irc/config.go @@ -1778,6 +1778,7 @@ func LoadConfig(filename string) (config *Config, err error) { if !config.Metadata.Enabled { config.Server.supportedCaps.Disable(caps.Metadata) + config.Server.supportedCaps.Disable(caps.MetadataDraft2) } else { metadataValues := make([]string, 0, 4) metadataValues = append(metadataValues, "before-connect") @@ -1795,6 +1796,7 @@ func LoadConfig(filename string) (config *Config, err error) { metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes)) } config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",") + config.Server.capValues[caps.MetadataDraft2] = strings.Join(metadataValues, ",") } err = config.processExtjwt() diff --git a/irc/getters.go b/irc/getters.go index fc85bce3..ee01df56 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -923,6 +923,22 @@ func (channel *Channel) ListMetadata() map[string]string { return maps.Clone(channel.metadata) } +func (channel *Channel) ListSubscribedMetadata(subscriptions utils.HashSet[string]) (result map[string]string) { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + + for k, v := range channel.metadata { + if subscriptions.Has(k) { + if result == nil { + result = make(map[string]string) + } + result[k] = v + } + } + + return result +} + func (channel *Channel) DeleteMetadata(key string) (updated bool) { defer channel.MarkDirty(IncludeAllAttrs) @@ -1032,6 +1048,22 @@ func (client *Client) ListMetadata() map[string]string { return maps.Clone(client.metadata) } +func (client *Client) ListSubscribedMetadata(subscriptions utils.HashSet[string]) (result map[string]string) { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + for k, v := range client.metadata { + if subscriptions.Has(k) { + if result == nil { + result = make(map[string]string) + } + result[k] = v + } + } + + return result +} + func (client *Client) DeleteMetadata(key string) (updated bool) { defer func() { if updated { diff --git a/irc/handlers.go b/irc/handlers.go index d83dc37b..ce3eccfa 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -765,6 +765,12 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response rb.session.capabilities.Subtract(toRemove) rb.Add(nil, server.name, "CAP", details.nick, "ACK", capString) + // XXX compatibility hack, if draft/metadata-2 was requested, silently enable draft/metadata-3 + // this way we only have to check for one cap at runtime + if rb.session.capabilities.Has(caps.MetadataDraft2) { + rb.session.capabilities.Add(caps.Metadata) + } + case "END": if !client.registered { rb.session.capState = caps.NegotiatedState @@ -3275,7 +3281,7 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res return metadataUnregisteredHandler(client, config, subcommand, msg.Params, rb) } default: - rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", utils.SafeErrorParam(msg.Params[1]), client.t("Invalid subcommand")) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_PARAMS", utils.SafeErrorParam(msg.Params[1]), client.t("Invalid subcommand")) return } } @@ -3296,7 +3302,10 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string var targetObj MetadataHaver var targetClient *Client var targetChannel *Channel - if strings.HasPrefix(target, "#") { + var syncAll bool + if subcommand == "sync" && strings.EqualFold(target, caps.MetadataSyncAllTarget) { + syncAll = true + } else if strings.HasPrefix(target, "#") { targetChannel = server.channels.Get(target) if targetChannel != nil { targetObj = targetChannel @@ -3309,7 +3318,7 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string target = targetClient.Nick() // canonicalize case } } - if targetObj == nil { + if !syncAll && targetObj == nil { rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_TARGET", target, client.t("Invalid metadata target")) return } @@ -3318,7 +3327,7 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string case "set": key := params[2] if metadataKeyIsEvil(key) { - rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_KEY", utils.SafeErrorParam(key), client.t("Invalid key name")) return } @@ -3339,19 +3348,19 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string } } - if len(params) > 3 { + if len(params) > 3 && params[3] != "" { value := params[3] config := client.server.Config() if failMsg := metadataValueIsEvil(config, key, value); failMsg != "" { - rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t(failMsg)) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_VALUE", utils.SafeErrorParam(key), client.t(failMsg)) return } updated, err := targetObj.SetMetadata(key, value, config.Metadata.MaxKeys) if err != nil { // errLimitExceeded is the only possible error - rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.t("Too many metadata keys")) + rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", target, strconv.Itoa(config.Metadata.MaxKeys), client.t("Too many metadata keys")) return } // echo the value to the client whether or not there was a real update @@ -3360,11 +3369,10 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string notifySubscribers(server, rb.session, targetObj, target, key, value, true) } } else { - if updated := targetObj.DeleteMetadata(key); updated { + updated := targetObj.DeleteMetadata(key) + rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key deleted")) + if updated { notifySubscribers(server, rb.session, targetObj, target, key, "", false) - rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key deleted")) - } else { - rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NOT_SET", utils.SafeErrorParam(key), client.t("Metadata key not set")) } } @@ -3389,7 +3397,7 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string for _, key := range params[2:] { if metadataKeyIsEvil(key) { - rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_KEY", utils.SafeErrorParam(key), client.t("Invalid key name")) continue } @@ -3412,6 +3420,10 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string playMetadataList(rb, client.Nick(), target, targetObj.ListMetadata()) case "sync": + if syncAll { + syncAllMetadata(server, rb) + return + } if !metadataCanISeeThisTarget(client, targetObj) { noKeyPerms("*") return @@ -3446,32 +3458,27 @@ func metadataUnregisteredHandler(client *Client, config *Config, subcommand stri } key := params[2] if metadataKeyIsEvil(key) { - rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_KEY", utils.SafeErrorParam(key), client.t("Invalid key name")) return } - if len(params) >= 4 { + if len(params) > 3 && params[3] != "" { value := params[3] // enforce a sane limit on prereg keys. we don't need to enforce the exact limit, // that will be done when applying the buffer after registration if len(rb.session.metadataPreregVals) > config.Metadata.MaxKeys { - rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.t("Too many metadata keys")) + rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", "*", strconv.Itoa(config.Metadata.MaxKeys), client.t("Too many metadata keys")) return } if failMsg := metadataValueIsEvil(config, key, value); failMsg != "" { - rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t(failMsg)) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_VALUE", utils.SafeErrorParam(key), client.t(failMsg)) return } rb.session.metadataPreregVals[key] = value rb.Add(nil, server.name, RPL_KEYVALUE, "*", "*", key, "*", value) } else { // unset - _, present := rb.session.metadataPreregVals[key] - if present { - delete(rb.session.metadataPreregVals, key) - rb.Add(nil, server.name, RPL_KEYNOTSET, "*", "*", key, client.t("Key deleted")) - } else { - rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NOT_SET", utils.SafeErrorParam(key), client.t("Metadata key not set")) - } + delete(rb.session.metadataPreregVals, key) + rb.Add(nil, server.name, RPL_KEYNOTSET, "*", "*", key, client.t("Key deleted")) } case "list": playMetadataList(rb, "*", "*", rb.session.metadataPreregVals) @@ -3480,7 +3487,7 @@ func metadataUnregisteredHandler(client *Client, config *Config, subcommand stri rb.session.metadataPreregVals = nil playMetadataList(rb, "*", "*", oldMetadata) case "sync": - rb.Add(nil, server.name, RPL_METADATASYNCLATER, "*", utils.SafeErrorParam(params[1]), "60") // lol + rb.Add(nil, server.name, RPL_METADATASYNCLATER, "*", utils.SafeErrorParam(params[1]), "60", "Try again later") // lol } return false } @@ -3494,24 +3501,29 @@ func metadataSubsHandler(client *Client, subcommand string, params []string, rb keys := params[2:] for _, key := range keys { if metadataKeyIsEvil(key) { - rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_KEY", utils.SafeErrorParam(key), client.t("Invalid key name")) return } } added, err := rb.session.SubscribeTo(keys...) if err == errMetadataTooManySubs { bad := keys[len(added)] // get the key that broke the camel's back - rb.Add(nil, server.name, "FAIL", "METADATA", "TOO_MANY_SUBS", utils.SafeErrorParam(bad), client.t("Too many subscriptions")) + limit := strconv.Itoa(server.Config().Metadata.MaxSubs) + rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", utils.SafeErrorParam(bad), limit, client.t("Too many subscriptions")) } - lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(client.Nick()) - 10 - + nick := client.Nick() + lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(nick) - 10 chunked := utils.ChunkifyParams(slices.Values(added), lineLength) for _, line := range chunked { - params := append([]string{client.Nick()}, line...) + params := append([]string{nick}, line...) rb.Add(nil, server.name, RPL_METADATASUBOK, params...) } + if client.registered && len(added) != 0 { + rb.Add(nil, server.name, RPL_METADATASYNCLATER, "*", caps.MetadataSyncAllTarget, "0", client.t("Try again later")) + } + case "unsub": keys := params[2:] removed := rb.session.UnsubscribeFrom(keys...) diff --git a/irc/metadata.go b/irc/metadata.go index 9e64e961..4825f1ee 100644 --- a/irc/metadata.go +++ b/irc/metadata.go @@ -59,9 +59,9 @@ func broadcastMetadataUpdate(server *Server, sessions iter.Seq[*Session], origin } if set { - s.Send(nil, server.name, "METADATA", target, key, "*", value) + s.Send(nil, server.name, RPL_KEYVALUE, "*", target, key, "*", value) } else { - s.Send(nil, server.name, "METADATA", target, key, "*") + s.Send(nil, server.name, RPL_KEYNOTSET, "*", target, key, s.client.t("Key deleted")) } } } @@ -75,7 +75,7 @@ func syncClientMetadata(server *Server, rb *ResponseBuffer, target *Client) { for k, v := range values { if subs.Has(k) { visibility := "*" - rb.Add(nil, server.name, "METADATA", target.Nick(), k, visibility, v) + rb.Add(nil, server.name, RPL_KEYVALUE, "*", target.Nick(), k, visibility, v) } } } @@ -91,7 +91,7 @@ func syncChannelMetadata(server *Server, rb *ResponseBuffer, channel *Channel) { for k, v := range values { if subs.Has(k) { visibility := "*" - rb.Add(nil, server.name, "METADATA", chname, k, visibility, v) + rb.Add(nil, server.name, RPL_KEYVALUE, "*", chname, k, visibility, v) } } @@ -100,12 +100,63 @@ func syncChannelMetadata(server *Server, rb *ResponseBuffer, channel *Channel) { for k, v := range values { if subs.Has(k) { visibility := "*" - rb.Add(nil, server.name, "METADATA", client.Nick(), k, visibility, v) + rb.Add(nil, server.name, RPL_KEYVALUE, "*", client.Nick(), k, visibility, v) } } } } +func syncAllMetadata(server *Server, rb *ResponseBuffer) { + client := rb.session.client + + batchID := rb.StartNestedBatch(nil, "metadata", "*ALL") + defer rb.EndNestedBatch(batchID) + + subs := rb.session.MetadataSubscriptions() + if len(subs) == 0 { + return + } + + // deduplicate friends across channels + friendsWithMetadata := make(map[*Client]map[string]string) + // include the client even if they have no channels + friendsWithMetadata[client] = client.ListSubscribedMetadata(subs) + + visibility := "*" + + for _, channel := range client.Channels() { + chname := channel.Name() + values := channel.ListSubscribedMetadata(subs) + for k, v := range values { + rb.Add(nil, server.name, RPL_KEYVALUE, "*", chname, k, visibility, v) + } + + for _, member := range channel.Members() { + if _, ok := friendsWithMetadata[member]; !ok { + friendsWithMetadata[member] = member.ListSubscribedMetadata(subs) + } + } + } + + for _, watchedNick := range server.monitorManager.List(rb.session) { + if watched := server.clients.Get(watchedNick); watched != nil { + if _, ok := friendsWithMetadata[watched]; !ok { + friendsWithMetadata[watched] = watched.ListSubscribedMetadata(subs) + } + } + } + + for friend, friendMetadata := range friendsWithMetadata { + if friendMetadata == nil { + continue + } + nick := friend.Nick() + for k, v := range friendMetadata { + rb.Add(nil, server.name, RPL_KEYVALUE, "*", nick, k, visibility, v) + } + } +} + func playMetadataList(rb *ResponseBuffer, nick, target string, values map[string]string) { batchId := rb.StartNestedBatch(nil, "metadata", target) defer rb.EndNestedBatch(batchId) @@ -131,7 +182,7 @@ func playMetadataVerbBatch(rb *ResponseBuffer, target string, values map[string] for key, val := range values { visibility := "*" - rb.Add(nil, rb.session.client.server.name, "METADATA", target, key, visibility, val) + rb.Add(nil, rb.session.client.server.name, RPL_KEYVALUE, "*", target, key, visibility, val) } } @@ -146,6 +197,12 @@ func metadataValueIsEvil(config *Config, key, value string) (failMsg string) { return `METADATA values must be UTF-8` } + if value == "" { + // we should never hit this case, + // since the command handlers should interpret empty value as "delete" + return `METADATA value MUST NOT be empty` + } + if len(key)+len(value) > maxCombinedMetadataLenBytes || (config.Metadata.MaxValueBytes > 0 && len(value) > config.Metadata.MaxValueBytes) { diff --git a/irc/monitor.go b/irc/monitor.go index 7019f64a..7fff434b 100644 --- a/irc/monitor.go +++ b/irc/monitor.go @@ -64,12 +64,19 @@ func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool, clie for _, session := range watchers { session.Send(nil, session.client.server.name, command, session.client.Nick(), nick) - if metadata != nil && session.capabilities.Has(caps.Metadata) { - for key := range session.MetadataSubscriptions() { + if online && session.capabilities.Has(caps.Metadata) { + // even if there is no user metadata, or no subscriptions, + // we still need to send an empty metadata batch alongside RPL_MONONLINE + subs := session.MetadataSubscriptions() + rb := NewResponseBuffer(session) + batchID := rb.StartNestedBatch(nil, "metadata", nick) + for key := range subs { if val, ok := metadata[key]; ok { - session.Send(nil, client.server.name, "METADATA", nick, key, "*", val) + rb.Add(nil, client.server.name, RPL_KEYVALUE, "*", nick, key, "*", val) } } + rb.EndNestedBatch(batchID) + rb.Send(false) } } } @@ -129,7 +136,9 @@ func (manager *MonitorManager) RemoveAll(session *Session) { func (manager *MonitorManager) List(session *Session) (nicks []string) { manager.RLock() defer manager.RUnlock() - for _, nick := range manager.watching[session] { + watching := manager.watching[session] + nicks = make([]string, 0, len(watching)) + for _, nick := range watching { nicks = append(nicks, nick) } return nicks diff --git a/irc/server.go b/irc/server.go index 372d108c..21edbf5e 100644 --- a/irc/server.go +++ b/irc/server.go @@ -478,6 +478,12 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { server.handleAutojoins(session, config.Channels.AutoJoin) } + if session.capabilities.Has(caps.Metadata) { + rb := NewResponseBuffer(session) + syncAllMetadata(server, rb) + rb.Send(true) + } + return false } diff --git a/irctest b/irctest index d18d6e35..21c620bc 160000 --- a/irctest +++ b/irctest @@ -1 +1 @@ -Subproject commit d18d6e35fe16c5bc7b4b62793d43e024de099623 +Subproject commit 21c620bc9c411c257d26d1db5807b212b432e51f