From 4dcbc48159e66fe5d426e62b9fd62d91403979a1 Mon Sep 17 00:00:00 2001 From: thatcher-gaming Date: Sun, 15 Jun 2025 09:06:45 +0100 Subject: [PATCH] metadata-2 (#2273) Initial implementation of draft/metadata-2 --- default.yaml | 10 +++ gencapdefs.py | 7 ++ irc/caps/defs.go | 7 +- irc/channel.go | 7 ++ irc/channelreg.go | 2 + irc/client.go | 4 + irc/commands.go | 4 + irc/config.go | 27 ++++++ irc/getters.go | 167 +++++++++++++++++++++++++++++++++++- irc/handlers.go | 197 +++++++++++++++++++++++++++++++++++++++++++ irc/help.go | 6 ++ irc/metadata.go | 164 +++++++++++++++++++++++++++++++++++ irc/metadata_test.go | 21 +++++ irc/numerics.go | 5 ++ irc/utils/chunks.go | 28 ++++++ traditional.yaml | 7 ++ 16 files changed, 660 insertions(+), 3 deletions(-) create mode 100644 irc/metadata.go create mode 100644 irc/metadata_test.go create mode 100644 irc/utils/chunks.go diff --git a/default.yaml b/default.yaml index f0342204..22fd27fd 100644 --- a/default.yaml +++ b/default.yaml @@ -1087,6 +1087,16 @@ history: # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. allow-environment-overrides: true +# experimental IRC metadata support for setting key/value data on channels and nicknames. +metadata: + # can clients store metadata? + enabled: true + # how many keys can a client subscribe to? + # set to 0 to disable subscriptions or -1 to allow unlimited subscriptions. + max-subs: 100 + # how many keys can a user store about themselves? set to -1 to allow unlimited keys. + max-keys: 1000 + # experimental support for mobile push notifications # see the manual for potential security, privacy, and performance implications. # DO NOT enable if you are running a Tor or I2P hidden service (i.e. one diff --git a/gencapdefs.py b/gencapdefs.py index 859aaa3d..45453c2b 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -237,6 +237,13 @@ CAPDEFS = [ url="https://github.com/ircv3/ircv3-specifications/pull/471", standard="Soju/Goguma vendor", ), + CapDef( + identifier="Metadata", + name="draft/metadata-2", + url="https://ircv3.net/specs/extensions/metadata", + standard="draft IRCv3", + ), + ] def validate_defs(): diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 5f747d49..a6377884 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 37 + numCapabs = 38 // length of the uint32 array that represents the bitset: bitsetLen = 2 ) @@ -65,6 +65,10 @@ 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": + // https://ircv3.net/specs/extensions/metadata + Metadata Capability = iota + // Multiline is the proposed IRCv3 capability named "draft/multiline": // https://github.com/ircv3/ircv3-specifications/pull/398 Multiline Capability = iota @@ -178,6 +182,7 @@ var ( "draft/extended-isupport", "draft/languages", "draft/message-redaction", + "draft/metadata-2", "draft/multiline", "draft/no-implicit-names", "draft/persistence", diff --git a/irc/channel.go b/irc/channel.go index 1dc55f43..1b449a70 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -55,6 +55,7 @@ type Channel struct { dirtyBits uint settings ChannelSettings uuid utils.UUID + metadata map[string]string // these caches are paired to allow iteration over channel members without holding the lock membersCache []*Client memberDataCache []*memberData @@ -126,6 +127,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) { channel.userLimit = chanReg.UserLimit channel.settings = chanReg.Settings channel.forward = chanReg.Forward + channel.metadata = chanReg.Metadata for _, mode := range chanReg.Modes { channel.flags.SetMode(mode, true) @@ -163,6 +165,7 @@ func (channel *Channel) ExportRegistration() (info RegisteredChannel) { info.AccountToUMode = maps.Clone(channel.accountToUMode) info.Settings = channel.settings + info.Metadata = channel.metadata return } @@ -892,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 1978b4ef..3e61e460 100644 --- a/irc/channelreg.go +++ b/irc/channelreg.go @@ -63,6 +63,8 @@ type RegisteredChannel struct { Invites map[string]MaskInfo // Settings are the chanserv-modifiable settings Settings ChannelSettings + // Metadata set using the METADATA command + Metadata map[string]string } func (r *RegisteredChannel) Serialize() ([]byte, error) { diff --git a/irc/client.go b/irc/client.go index 2e814ce9..25aa72cf 100644 --- a/irc/client.go +++ b/irc/client.go @@ -131,6 +131,7 @@ type Client struct { clearablePushMessages map[string]time.Time pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0 pushQueue pushQueue + metadata map[string]string } type saslStatus struct { @@ -214,6 +215,8 @@ type Session struct { batch MultilineBatch webPushEndpoint string // goroutine-local: web push endpoint registered by the current session + + metadataSubscriptions utils.HashSet[string] } // MultilineBatch tracks the state of a client-to-server multiline batch. @@ -1129,6 +1132,7 @@ func (client *Client) SetNick(nick, nickCasefolded, skeleton string) (success bo client.nickCasefolded = nickCasefolded client.skeleton = skeleton client.updateNickMaskNoMutex() + return true } diff --git a/irc/commands.go b/irc/commands.go index 54016b96..d5387306 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -209,6 +209,10 @@ func init() { handler: markReadHandler, minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS }, + "METADATA": { + handler: metadataHandler, + minParams: 2, + }, "MODE": { handler: modeHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index b10e08e6..67f2ec03 100644 --- a/irc/config.go +++ b/irc/config.go @@ -723,6 +723,14 @@ type Config struct { } `yaml:"tagmsg-storage"` } + Metadata struct { + // BeforeConnect int `yaml:"before-connect"` todo: this + Enabled bool + MaxSubs int `yaml:"max-subs"` + MaxKeys int `yaml:"max-keys"` + MaxValueBytes int `yaml:"max-value-length"` // todo: currently unenforced!! + } + WebPush struct { Enabled bool Timeout time.Duration @@ -1637,6 +1645,25 @@ func LoadConfig(filename string) (config *Config, err error) { } } + if !config.Metadata.Enabled { + config.Server.supportedCaps.Disable(caps.Metadata) + } else { + var metadataValues []string + if config.Metadata.MaxSubs >= 0 { + metadataValues = append(metadataValues, fmt.Sprintf("max-subs=%d", config.Metadata.MaxSubs)) + } + if config.Metadata.MaxKeys > 0 { + metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys)) + } + if config.Metadata.MaxValueBytes > 0 { + metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes)) + } + if len(metadataValues) != 0 { + config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",") + } + + } + err = config.processExtjwt() if err != nil { return nil, err diff --git a/irc/getters.go b/irc/getters.go index 29f97c75..5feadaa3 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "net" + "slices" "time" "github.com/ergochat/ergo/irc/caps" @@ -797,10 +798,12 @@ func (channel *Channel) Settings() (result ChannelSettings) { } func (channel *Channel) SetSettings(settings ChannelSettings) { + defer channel.MarkDirty(IncludeSettings) + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + channel.settings = settings - channel.stateMutex.Unlock() - channel.MarkDirty(IncludeSettings) } func (channel *Channel) setForward(forward string) { @@ -827,3 +830,163 @@ func (channel *Channel) UUID() utils.UUID { defer channel.stateMutex.RUnlock() return channel.uuid } + +func (session *Session) isSubscribedTo(key string) bool { + session.client.stateMutex.RLock() + defer session.client.stateMutex.RUnlock() + + 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 !session.metadataSubscriptions.Has(k) { + if len(session.metadataSubscriptions) > maxSubs { + return added, errMetadataTooManySubs + } + added = append(added, k) + session.metadataSubscriptions.Add(k) + } + } + + return added, nil +} + +func (session *Session) UnsubscribeFrom(keys ...string) []string { + session.client.stateMutex.Lock() + defer session.client.stateMutex.Unlock() + + var removed []string + + for k := range session.metadataSubscriptions { + if slices.Contains(keys, k) { + removed = append(removed, k) + session.metadataSubscriptions.Remove(k) + } + } + + return removed +} + +func (session *Session) MetadataSubscriptions() utils.HashSet[string] { + session.client.stateMutex.Lock() + defer session.client.stateMutex.Unlock() + + return maps.Clone(session.metadataSubscriptions) +} + +func (channel *Channel) GetMetadata(key string) (string, bool) { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + + val, ok := channel.metadata[key] + return val, ok +} + +func (channel *Channel) SetMetadata(key string, value string) { + defer channel.MarkDirty(IncludeAllAttrs) + + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + + if channel.metadata == nil { + channel.metadata = make(map[string]string) + } + + channel.metadata[key] = value +} + +func (channel *Channel) ListMetadata() map[string]string { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + + return maps.Clone(channel.metadata) +} + +func (channel *Channel) DeleteMetadata(key string) { + defer channel.MarkDirty(IncludeAllAttrs) + + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + + delete(channel.metadata, key) +} + +func (channel *Channel) ClearMetadata() map[string]string { + defer channel.MarkDirty(IncludeAllAttrs) + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + + oldMap := channel.metadata + channel.metadata = nil + + return oldMap +} + +func (channel *Channel) CountMetadata() int { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + + return len(channel.metadata) +} + +func (client *Client) GetMetadata(key string) (string, bool) { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + val, ok := client.metadata[key] + return val, ok +} + +func (client *Client) SetMetadata(key string, value string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.metadata == nil { + client.metadata = make(map[string]string) + } + + client.metadata[key] = value +} + +func (client *Client) ListMetadata() map[string]string { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + return maps.Clone(client.metadata) +} + +func (client *Client) DeleteMetadata(key string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + delete(client.metadata, key) +} + +func (client *Client) ClearMetadata() map[string]string { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + oldMap := client.metadata + client.metadata = nil + + return oldMap +} + +func (client *Client) CountMetadata() int { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + return len(client.metadata) +} diff --git a/irc/handlers.go b/irc/handlers.go index c606637f..6d727268 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" @@ -3097,6 +3099,201 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res return } +// METADATA [...] +func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { + originalTarget := msg.Params[0] + target := originalTarget + + if !server.Config().Metadata.Enabled { + rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", originalTarget, "Metadata is disabled on this server") + return + } + + subcommand := strings.ToLower(msg.Params[1]) + + invalidTarget := func() { + rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_TARGET", originalTarget, client.t("Invalid metadata target")) + } + noKeyPerms := func(key string) { + rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", originalTarget, key, client.t("You do not have permission to perform this action")) + } + + if target == "*" { + target = client.Nick() + } + + targetClient := server.clients.Get(target) + targetChannel := server.channels.Get(target) + if !metadataCanISeeThisTarget(client, target) { + invalidTarget() + return + } + + var t MetadataHaver + if targetClient != nil { + t = targetClient + } + if targetChannel != nil { + t = targetChannel + } + if t == nil { + invalidTarget() + return + } + + needsKey := subcommand == "set" || subcommand == "get" || subcommand == "sub" || subcommand == "unsub" + if needsKey && len(msg.Params) <= 2 { + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) + return + } + + switch subcommand { + case "set": + key := strings.ToLower(msg.Params[2]) + if metadataKeyIsEvil(key) { + rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", key, client.t("Invalid key name")) + return + } + + if !metadataCanIEditThisKey(client, target, key) { + noKeyPerms(key) + return + } + + if len(msg.Params) > 3 { + value := msg.Params[3] + const maxCombinedLen = 350 + + if len(key)+len(value) > maxCombinedLen { + rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t("Value is too long")) + return + } + + maxKeys := server.Config().Metadata.MaxKeys + isSelf := targetClient != nil && client == targetClient + + if isSelf && maxKeys > 0 && t.CountMetadata() >= maxKeys { + rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.nick, client.t("You have too many keys set on yourself")) + return + } + + server.logger.Debug("metadata", "setting", key, value, "on", target) + + t.SetMetadata(key, value) + notifySubscribers(server, rb.session, target, key, value) + + rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, "*", value) + } else { + server.logger.Debug("metadata", "deleting", key, "on", target) + t.DeleteMetadata(key) + notifySubscribers(server, rb.session, target, key, "") + + rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key deleted")) + } + + case "get": + batchId := rb.StartNestedBatch("metadata") + defer rb.EndNestedBatch(batchId) + + for _, key := range msg.Params[2:] { + if metadataKeyIsEvil(key) { + rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", key, client.t("Invalid key name")) + continue + } + + val, ok := t.GetMetadata(key) + if !ok { + rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key is not set")) + continue + } + + visibility := "*" + rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, visibility, val) + } + + case "list": + values := t.ListMetadata() + + batchId := rb.StartNestedBatch("metadata") + defer rb.EndNestedBatch(batchId) + + for key, val := range values { + visibility := "*" + + rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, visibility, val) + } + + case "clear": + if !metadataCanIEditThisTarget(client, target) { + invalidTarget() + return + } + + values := t.ClearMetadata() + + batchId := rb.StartNestedBatch("metadata") + defer rb.EndNestedBatch(batchId) + + for key, val := range values { + visibility := "*" + rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, visibility, val) + } + + case "sub": + keys := msg.Params[2:] + server.logger.Debug("metadata", client.nick, "has subscrumbled to", strings.Join(keys, ", ")) + 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", bad, client.t("Too many subscriptions")) + } + + lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(client.Nick()) - 10 + + chunked := utils.ChunkifyParams(slices.Values(added), lineLength) + for _, line := range chunked { + params := append([]string{client.Nick()}, line...) + rb.Add(nil, server.name, RPL_METADATASUBOK, params...) + } + + case "unsub": + keys := msg.Params[2:] + server.logger.Debug("metadata", client.nick, "has UNsubscrumbled to", strings.Join(keys, ", ")) + removed := rb.session.UnsubscribeFrom(keys...) + + lineLength := MaxLineLen - len(server.name) - len(RPL_METADATAUNSUBOK) - len(client.Nick()) - 10 + chunked := utils.ChunkifyParams(slices.Values(removed), lineLength) + for _, line := range chunked { + params := append([]string{client.Nick()}, line...) + rb.Add(nil, server.name, RPL_METADATAUNSUBOK, params...) + } + + case "subs": + lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10 // for safety + + 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": + if targetChannel != nil { + syncChannelMetadata(server, rb, targetChannel) + } + if targetClient != nil { + syncClientMetadata(server, rb, targetClient) + } + + default: + rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", msg.Params[1], client.t("Invalid subcommand")) + } + + return +} + // REHASH func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { nick := client.Nick() diff --git a/irc/help.go b/irc/help.go index 6a6824a0..d516c43e 100644 --- a/irc/help.go +++ b/irc/help.go @@ -339,6 +339,12 @@ command is processed by that server.`, MARKREAD updates an IRCv3 read message marker. It is not intended for use by end users. For more details, see the latest draft of the read-marker specification.`, + }, + "metadata": { + text: `METADATA [...] + +Retrieve and meddle with metadata for the given target. +Have a look at https://ircv3.net/specs/extensions/metadata for interesting technical information.`, }, "mode": { text: `MODE [ [...]] diff --git a/irc/metadata.go b/irc/metadata.go new file mode 100644 index 00000000..d7094065 --- /dev/null +++ b/irc/metadata.go @@ -0,0 +1,164 @@ +package irc + +import ( + "errors" + "regexp" + "strings" + + "github.com/ergochat/ergo/irc/caps" + "github.com/ergochat/ergo/irc/modes" + "github.com/ergochat/ergo/irc/utils" +) + +var ( + errMetadataTooManySubs = errors.New("too many subscriptions") + errMetadataNotFound = errors.New("key not found") +) + +type MetadataHaver = interface { + SetMetadata(key string, value string) + GetMetadata(key string) (string, bool) + DeleteMetadata(key string) + ListMetadata() map[string]string + ClearMetadata() map[string]string + CountMetadata() int +} + +func notifySubscribers(server *Server, session *Session, target string, key string, value string) { + var notify utils.HashSet[*Session] = make(utils.HashSet[*Session]) + targetChannel := server.channels.Get(target) + targetClient := server.clients.Get(target) + + if targetClient != nil { + notify = targetClient.FriendsMonitors(caps.Metadata) + // notify clients about changes regarding themselves + for _, s := range targetClient.Sessions() { + notify.Add(s) + } + } + if targetChannel != nil { + members := targetChannel.Members() + for _, m := range members { + for _, s := range m.Sessions() { + if s.capabilities.Has(caps.Metadata) { + notify.Add(s) + } + } + } + } + + // don't notify the session that made the change + notify.Remove(session) + + for s := range notify { + if !s.isSubscribedTo(key) { + continue + } + + if value != "" { + s.Send(nil, server.name, "METADATA", target, key, "*", value) + } else { + s.Send(nil, server.name, "METADATA", target, key, "*") + } + } +} + +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 { + key = strings.TrimSpace(key) // just in case + + return len(key) == 0 || // key needs to contain stuff + key[0] == ':' || // key can't start with a colon + metadataEvilCharsRegexp.MatchString(key) // key can't contain the stuff it can't contain +} + +func metadataCanIEditThisKey(client *Client, target string, _ string) bool { + if !metadataCanIEditThisTarget(client, target) { // you can't edit keys on targets you can't edit. + return false + } + + // todo: we don't actually do anything regarding visibility yet so there's not much to do here + + return true +} + +func metadataCanIEditThisTarget(client *Client, target string) bool { + if !metadataCanISeeThisTarget(client, target) { // you can't edit what you can't see. a wise man told me this once + return false + } + + if client.HasRoleCapabs("sajoin") { // sajoin opers can do whatever they want + return true + } + + if target == client.Nick() { // your right to swing your fist ends where my nose begins + return true + } + + // if you're a channel operator, knock yourself out + channel := client.server.channels.Get(target) + if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) { + return true + } + + return false +} + +func metadataCanISeeThisTarget(client *Client, target string) bool { + if client.HasRoleCapabs("sajoin") { // sajoin opers can do whatever they want + return true + } + + // check if the user is in the channel + channel := client.server.channels.Get(target) + if channel != nil && !channel.hasClient(client) { + return false + } + + return true +} diff --git a/irc/metadata_test.go b/irc/metadata_test.go new file mode 100644 index 00000000..93942abb --- /dev/null +++ b/irc/metadata_test.go @@ -0,0 +1,21 @@ +package irc + +import "testing" + +func TestKeyCheck(t *testing.T) { + cases := []struct { + input string + isEvil bool + }{ + {"ImNormal", false}, + {":imevil", true}, + {"key£with$not%allowed^chars", true}, + {"key.that:s_completely/normal-and.fine", false}, + } + + for _, c := range cases { + if metadataKeyIsEvil(c.input) != c.isEvil { + t.Errorf("%s should have returned %v. but it didn't. so that's not great", c.input, c.isEvil) + } + } +} diff --git a/irc/numerics.go b/irc/numerics.go index 97d4604d..bf5a6d60 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -183,6 +183,11 @@ const ( RPL_MONLIST = "732" RPL_ENDOFMONLIST = "733" ERR_MONLISTFULL = "734" + RPL_KEYVALUE = "761" // metadata numerics + RPL_KEYNOTSET = "766" + RPL_METADATASUBOK = "770" + RPL_METADATAUNSUBOK = "771" + RPL_METADATASUBS = "772" RPL_LOGGEDIN = "900" RPL_LOGGEDOUT = "901" ERR_NICKLOCKED = "902" diff --git a/irc/utils/chunks.go b/irc/utils/chunks.go new file mode 100644 index 00000000..30376626 --- /dev/null +++ b/irc/utils/chunks.go @@ -0,0 +1,28 @@ +package utils + +import "iter" + +func ChunkifyParams(params iter.Seq[string], maxChars int) [][]string { + var chunked [][]string + + var acc []string + var length = 0 + + for p := range params { + length = length + len(p) + 1 // (accounting for the space) + + if length > maxChars { + chunked = append(chunked, acc) + acc = []string{} + length = 0 + } + + acc = append(acc, p) + } + + if len(acc) != 0 { + chunked = append(chunked, acc) + } + + return chunked +} diff --git a/traditional.yaml b/traditional.yaml index df6ff052..55f16ea7 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -1058,6 +1058,13 @@ history: # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. allow-environment-overrides: true +# experimental IRC metadata support for setting key/value data on channels and nicknames. +metadata: + # can clients use the metadata command? + enabled: true + # how many keys can a client subscribe to? + max-subs: 1000 + # experimental support for mobile push notifications # see the manual for potential security, privacy, and performance implications. # DO NOT enable if you are running a Tor or I2P hidden service (i.e. one