package irc import ( "errors" "iter" "maps" "regexp" "unicode/utf8" "github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/modes" ) const ( // metadata key + value need to be relayable on a single IRC RPL_KEYVALUE line maxCombinedMetadataLenBytes = 350 ) var ( errMetadataTooManySubs = errors.New("too many subscriptions") errMetadataNotFound = errors.New("key not found") ) type MetadataHaver interface { SetMetadata(key string, value string, limit int) (updated bool, err error) GetMetadata(key string) (string, bool) DeleteMetadata(key string) (updated bool) ListMetadata() map[string]string ClearMetadata() map[string]string CountMetadata() int } func notifySubscribers(server *Server, session *Session, targetObj MetadataHaver, targetName, key, value string, set bool) { var recipientSessions iter.Seq[*Session] switch target := targetObj.(type) { case *Client: // TODO this case is expensive and might warrant rate-limiting friends := target.FriendsMonitors(caps.Metadata) // broadcast metadata update to other connected sessions for _, s := range target.Sessions() { friends.Add(s) } recipientSessions = maps.Keys(friends) case *Channel: recipientSessions = target.sessionsWithCaps(caps.Metadata) default: return // impossible } broadcastMetadataUpdate(server, recipientSessions, session, targetName, key, value, set) } func broadcastMetadataUpdate(server *Server, sessions iter.Seq[*Session], originator *Session, target, key, value string, set bool) { for s := range sessions { // don't notify the session that made the change if s == originator || !s.isSubscribedTo(key) { continue } if set { s.Send(nil, server.name, RPL_KEYVALUE, "*", target, key, "*", value) } else { s.Send(nil, server.name, RPL_KEYNOTSET, "*", target, key, s.client.t("Key deleted")) } } } func syncClientMetadata(server *Server, rb *ResponseBuffer, target *Client) { batchId := rb.StartNestedBatch(nil, "metadata", target.Nick()) defer rb.EndNestedBatch(batchId) subs := rb.session.MetadataSubscriptions() values := target.ListMetadata() for k, v := range values { if subs.Has(k) { visibility := "*" rb.Add(nil, server.name, RPL_KEYVALUE, "*", target.Nick(), k, visibility, v) } } } func syncChannelMetadata(server *Server, rb *ResponseBuffer, channel *Channel) { batchId := rb.StartNestedBatch(nil, "metadata", channel.Name()) defer rb.EndNestedBatch(batchId) subs := rb.session.MetadataSubscriptions() chname := channel.Name() values := channel.ListMetadata() for k, v := range values { if subs.Has(k) { visibility := "*" rb.Add(nil, server.name, RPL_KEYVALUE, "*", chname, k, visibility, v) } } for _, client := range channel.Members() { values := client.ListMetadata() for k, v := range values { if subs.Has(k) { visibility := "*" 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) for key, val := range values { visibility := "*" rb.Add(nil, rb.session.client.server.name, RPL_KEYVALUE, nick, target, key, visibility, val) } } func playMetadataClear(rb *ResponseBuffer, nick, target string, values map[string]string) { batchId := rb.StartNestedBatch(nil, "metadata", target) defer rb.EndNestedBatch(batchId) for key := range values { rb.Add(nil, rb.session.client.server.name, RPL_KEYNOTSET, nick, target, key, rb.session.client.t("Metadata value cleared")) } } func playMetadataVerbBatch(rb *ResponseBuffer, target string, values map[string]string) { batchId := rb.StartNestedBatch(nil, "metadata", target) defer rb.EndNestedBatch(batchId) for key, val := range values { visibility := "*" rb.Add(nil, rb.session.client.server.name, RPL_KEYVALUE, "*", target, key, visibility, val) } } var validMetadataKeyRegexp = regexp.MustCompile("^[a-z0-9_./-]+$") func metadataKeyIsEvil(key string) bool { return !validMetadataKeyRegexp.MatchString(key) } func metadataValueIsEvil(config *Config, key, value string) (failMsg string) { if !globalUtf8EnforcementSetting && !utf8.ValidString(value) { 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) { return `Value is too long` } return "" // success } func metadataCanIEditThisKey(client *Client, targetObj MetadataHaver, key string) bool { // no key-specific logic as yet return metadataCanIEditThisTarget(client, targetObj) } func metadataCanIEditThisTarget(client *Client, targetObj MetadataHaver) bool { switch target := targetObj.(type) { case *Client: return client == target || client.HasRoleCapabs("metadata") case *Channel: return target.ClientIsAtLeast(client, modes.Operator) || client.HasRoleCapabs("metadata") default: return false // impossible } } func metadataCanISeeThisTarget(client *Client, targetObj MetadataHaver) bool { switch target := targetObj.(type) { case *Client: return true case *Channel: return target.hasClient(client) || client.HasRoleCapabs("metadata") default: return false // impossible } }