diff --git a/gencapdefs.py b/gencapdefs.py index ddbf5bcc..45453c2b 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -238,7 +238,7 @@ CAPDEFS = [ standard="Soju/Goguma vendor", ), CapDef( - identifier="MetadataTwoJudgementDay", + identifier="Metadata", name="draft/metadata-2", url="https://ircv3.net/specs/extensions/metadata", standard="draft IRCv3", diff --git a/irc/caps/defs.go b/irc/caps/defs.go index cb78a430..a6377884 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -65,9 +65,9 @@ const ( // https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md MessageRedaction Capability = iota - // MetadataTwoJudgementDay is the draft IRCv3 capability named "draft/metadata-2": + // Metadata is the draft IRCv3 capability named "draft/metadata-2": // https://ircv3.net/specs/extensions/metadata - MetadataTwoJudgementDay Capability = iota + Metadata Capability = iota // Multiline is the proposed IRCv3 capability named "draft/multiline": // https://github.com/ircv3/ircv3-specifications/pull/398 diff --git a/irc/channel.go b/irc/channel.go index 2212570e..1b449a70 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -55,7 +55,7 @@ type Channel struct { dirtyBits uint settings ChannelSettings uuid utils.UUID - metadata MetadataStore + metadata map[string]string // these caches are paired to allow iteration over channel members without holding the lock membersCache []*Client memberDataCache []*memberData @@ -895,6 +895,10 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp rb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname)) } + if rb.session.capabilities.Has(caps.Metadata) { + syncChannelMetadata(client.server, rb, channel) + } + if rb.session.client == client { // don't send topic and names for a SAJOIN of a different client channel.SendTopic(client, rb, false) diff --git a/irc/channelreg.go b/irc/channelreg.go index 03e23dfd..3e61e460 100644 --- a/irc/channelreg.go +++ b/irc/channelreg.go @@ -64,7 +64,7 @@ type RegisteredChannel struct { // Settings are the chanserv-modifiable settings Settings ChannelSettings // Metadata set using the METADATA command - Metadata MetadataStore + Metadata map[string]string } func (r *RegisteredChannel) Serialize() ([]byte, error) { diff --git a/irc/client.go b/irc/client.go index 15ee0f73..25aa72cf 100644 --- a/irc/client.go +++ b/irc/client.go @@ -131,7 +131,7 @@ type Client struct { clearablePushMessages map[string]time.Time pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0 pushQueue pushQueue - metadata MetadataStore + metadata map[string]string } type saslStatus struct { @@ -216,7 +216,7 @@ type Session struct { webPushEndpoint string // goroutine-local: web push endpoint registered by the current session - metadataSubscriptions []string + metadataSubscriptions utils.HashSet[string] } // MultilineBatch tracks the state of a client-to-server multiline batch. diff --git a/irc/config.go b/irc/config.go index 37e1fad4..67f2ec03 100644 --- a/irc/config.go +++ b/irc/config.go @@ -1646,7 +1646,7 @@ func LoadConfig(filename string) (config *Config, err error) { } if !config.Metadata.Enabled { - config.Server.supportedCaps.Disable(caps.MetadataTwoJudgementDay) + config.Server.supportedCaps.Disable(caps.Metadata) } else { var metadataValues []string if config.Metadata.MaxSubs >= 0 { @@ -1659,7 +1659,7 @@ func LoadConfig(filename string) (config *Config, err error) { metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes)) } if len(metadataValues) != 0 { - config.Server.capValues[caps.MetadataTwoJudgementDay] = strings.Join(metadataValues, ",") + config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",") } } diff --git a/irc/getters.go b/irc/getters.go index 4aa8c1fb..7b0d5f9a 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -833,24 +833,32 @@ func (session *Session) isSubscribedTo(key string) bool { session.client.stateMutex.RLock() defer session.client.stateMutex.RUnlock() - return slices.Contains(session.metadataSubscriptions, key) + if session.metadataSubscriptions == nil { + return false + } + + return session.metadataSubscriptions.Has(key) } func (session *Session) SubscribeTo(keys ...string) ([]string, error) { session.client.stateMutex.Lock() defer session.client.stateMutex.Unlock() + if session.metadataSubscriptions == nil { + session.metadataSubscriptions = make(utils.HashSet[string]) + } + var added []string maxSubs := session.client.server.Config().Metadata.MaxSubs for _, k := range keys { - if !slices.Contains(session.metadataSubscriptions, k) { + if !session.metadataSubscriptions.Has(k) { if len(session.metadataSubscriptions) > maxSubs { return added, errMetadataTooManySubs } added = append(added, k) - session.metadataSubscriptions = append(session.metadataSubscriptions, k) + session.metadataSubscriptions.Add(k) } } @@ -863,27 +871,25 @@ func (session *Session) UnsubscribeFrom(keys ...string) []string { var removed []string - new := slices.DeleteFunc(session.metadataSubscriptions, - func(keyName string) bool { - if slices.Contains(keys, keyName) { - removed = append(removed, keyName) - return true - } else { - return false - } - }, - ) + if session.metadataSubscriptions == nil { + return []string{} + } - session.metadataSubscriptions = new + for k := range session.metadataSubscriptions { + if slices.Contains(keys, k) { + removed = append(removed, k) + session.metadataSubscriptions.Remove(k) + } + } return removed } -func (session *Session) MetadataSubscriptions() []string { +func (session *Session) MetadataSubscriptions() utils.HashSet[string] { session.client.stateMutex.Lock() defer session.client.stateMutex.Unlock() - return slices.Clone(session.metadataSubscriptions) + return maps.Clone(session.metadataSubscriptions) } func (channel *Channel) GetMetadata(key string) (string, error) { @@ -901,7 +907,7 @@ func (channel *Channel) SetMetadata(key string, value string) { channel.stateMutex.Lock() if channel.metadata == nil { - channel.metadata = make(MetadataStore) + channel.metadata = make(map[string]string) } channel.metadata[key] = value @@ -909,7 +915,7 @@ func (channel *Channel) SetMetadata(key string, value string) { channel.MarkDirty(IncludeAllAttrs) } -func (channel *Channel) ListMetadata() MetadataStore { +func (channel *Channel) ListMetadata() map[string]string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() @@ -924,11 +930,11 @@ func (channel *Channel) DeleteMetadata(key string) { channel.MarkDirty(IncludeAllAttrs) } -func (channel *Channel) ClearMetadata() MetadataStore { +func (channel *Channel) ClearMetadata() map[string]string { channel.stateMutex.Lock() oldMap := channel.metadata - channel.metadata = make(MetadataStore) + channel.metadata = make(map[string]string) channel.stateMutex.Unlock() channel.MarkDirty(IncludeAllAttrs) @@ -962,15 +968,13 @@ func (client *Client) SetMetadata(key string, value string) { defer client.stateMutex.Unlock() if client.metadata == nil { - client.metadata = make(MetadataStore) + client.metadata = make(map[string]string) } client.metadata[key] = value - - // coming soon: https://www.youtube.com/watch?v=K14JkFfWUzc } -func (client *Client) ListMetadata() MetadataStore { +func (client *Client) ListMetadata() map[string]string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() @@ -984,12 +988,12 @@ func (client *Client) DeleteMetadata(key string) { delete(client.metadata, key) } -func (client *Client) ClearMetadata() MetadataStore { +func (client *Client) ClearMetadata() map[string]string { client.stateMutex.Lock() defer client.stateMutex.Unlock() oldMap := client.metadata - client.metadata = make(MetadataStore) + client.metadata = make(map[string]string) return oldMap } diff --git a/irc/handlers.go b/irc/handlers.go index b53d4f0d..2311b161 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -9,11 +9,13 @@ package irc import ( "bytes" "fmt" + "maps" "net" "os" "runtime" "runtime/debug" "runtime/pprof" + "slices" "sort" "strconv" "strings" @@ -3248,10 +3250,10 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(client.Nick()) - 10 - chunked := utils.ChunkifyParams(added, lineLength) + chunked := utils.ChunkifyParams(slices.Values(added), lineLength) for _, line := range chunked { params := append([]string{client.Nick()}, line...) - rb.Add(nil, server.name, RPL_METADATASUBS, params...) + rb.Add(nil, server.name, RPL_METADATASUBOK, params...) } case "unsub": @@ -3260,42 +3262,29 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res removed := rb.session.UnsubscribeFrom(keys...) lineLength := MaxLineLen - len(server.name) - len(RPL_METADATAUNSUBOK) - len(client.Nick()) - 10 - chunked := utils.ChunkifyParams(removed, lineLength) + chunked := utils.ChunkifyParams(slices.Values(removed), lineLength) for _, line := range chunked { params := append([]string{client.Nick()}, line...) - rb.Add(nil, server.name, RPL_METADATASUBS, params...) + rb.Add(nil, server.name, RPL_METADATAUNSUBOK, params...) } case "subs": lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10 // for safety - chunked := utils.ChunkifyParams(rb.session.MetadataSubscriptions(), lineLength) + subs := rb.session.MetadataSubscriptions() + + chunked := utils.ChunkifyParams(maps.Keys(subs), lineLength) for _, line := range chunked { params := append([]string{client.Nick()}, line...) rb.Add(nil, server.name, RPL_METADATASUBS, params...) } case "sync": - batchId := rb.StartNestedBatch("metadata") - defer rb.EndNestedBatch(batchId) - - values := t.ListMetadata() - for k, v := range values { - if rb.session.isSubscribedTo(k) { - visibility := "*" - rb.Add(nil, server.name, "METADATA", target, k, visibility, v) - } - } if targetChannel != nil { - for _, client := range targetChannel.Members() { - values := client.ListMetadata() - for k, v := range values { - if rb.session.isSubscribedTo(k) { - visibility := "*" - rb.Add(nil, server.name, "METADATA", client.Nick(), k, visibility, v) - } - } - } + syncChannelMetadata(server, rb, targetChannel) + } + if targetClient != nil { + syncClientMetadata(server, rb, targetClient) } default: diff --git a/irc/metadata.go b/irc/metadata.go index 62242cc4..8028731f 100644 --- a/irc/metadata.go +++ b/irc/metadata.go @@ -15,14 +15,12 @@ var ( errMetadataNotFound = errors.New("key not found") ) -type MetadataStore = map[string]string - type MetadataHaver = interface { SetMetadata(key string, value string) GetMetadata(key string) (string, error) DeleteMetadata(key string) - ListMetadata() MetadataStore - ClearMetadata() MetadataStore + ListMetadata() map[string]string + ClearMetadata() map[string]string CountMetadata() int } @@ -32,7 +30,7 @@ func notifySubscribers(server *Server, session *Session, target string, key stri targetClient := server.clients.Get(target) if targetClient != nil { - notify = targetClient.FriendsMonitors(caps.MetadataTwoJudgementDay) + notify = targetClient.FriendsMonitors(caps.Metadata) // notify clients about changes regarding themselves for _, s := range targetClient.Sessions() { notify.Add(s) @@ -42,7 +40,7 @@ func notifySubscribers(server *Server, session *Session, target string, key stri members := targetChannel.Members() for _, m := range members { for _, s := range m.Sessions() { - if s.capabilities.Has(caps.MetadataTwoJudgementDay) { + if s.capabilities.Has(caps.Metadata) { notify.Add(s) } } @@ -65,6 +63,50 @@ func notifySubscribers(server *Server, session *Session, target string, key stri } } +func syncClientMetadata(server *Server, rb *ResponseBuffer, target *Client) { + if len(rb.session.MetadataSubscriptions()) == 0 { + return + } + + batchId := rb.StartNestedBatch("metadata") + defer rb.EndNestedBatch(batchId) + + values := target.ListMetadata() + for k, v := range values { + if rb.session.isSubscribedTo(k) { + visibility := "*" + rb.Add(nil, server.name, "METADATA", target.Nick(), k, visibility, v) + } + } +} + +func syncChannelMetadata(server *Server, rb *ResponseBuffer, target *Channel) { + if len(rb.session.MetadataSubscriptions()) == 0 { + return + } + + batchId := rb.StartNestedBatch("metadata") + defer rb.EndNestedBatch(batchId) + + values := target.ListMetadata() + for k, v := range values { + if rb.session.isSubscribedTo(k) { + visibility := "*" + rb.Add(nil, server.name, "METADATA", target.Name(), k, visibility, v) + } + } + + for _, client := range target.Members() { + values := client.ListMetadata() + for k, v := range values { + if rb.session.isSubscribedTo(k) { + visibility := "*" + rb.Add(nil, server.name, "METADATA", client.Nick(), k, visibility, v) + } + } + } +} + var metadataEvilCharsRegexp = regexp.MustCompile("[^A-Za-z0-9_./:-]+") func metadataKeyIsEvil(key string) bool { diff --git a/irc/utils/chunks.go b/irc/utils/chunks.go index fb541ec5..30376626 100644 --- a/irc/utils/chunks.go +++ b/irc/utils/chunks.go @@ -1,12 +1,14 @@ package utils -func ChunkifyParams(params []string, maxChars int) [][]string { +import "iter" + +func ChunkifyParams(params iter.Seq[string], maxChars int) [][]string { var chunked [][]string var acc []string var length = 0 - for _, p := range params { + for p := range params { length = length + len(p) + 1 // (accounting for the space) if length > maxChars { @@ -18,5 +20,9 @@ func ChunkifyParams(params []string, maxChars int) [][]string { acc = append(acc, p) } + if len(acc) != 0 { + chunked = append(chunked, acc) + } + return chunked }