3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-06-22 06:37:30 +02:00

round 1 of follow-up for metadata (#2277)

* refactoring
* send an empty batch if necessary, as per spec
* don't broadcast no-op updates
* don't trim spaces before validating the key
* bump irctest to cover metadata
* replay existing metadata to reattaching always-on clients
* use canonicalized name everywhere
* use utils.SafeErrorParam in FAIL lines
* validate key names for sub
* fix error for METADATA CLEAR
* max-keys is enforced for channels as well
* remove unlimited configurations
* maintain the limit exactly without off-by-one cases
* add final channel registration check
This commit is contained in:
Shivaram Lingamneni 2025-06-18 00:22:49 -04:00 committed by GitHub
parent 4dcbc48159
commit 3b7db7fff7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 213 additions and 172 deletions

View File

@ -724,6 +724,7 @@ oper-classes:
- "history" # modify or delete history messages
- "defcon" # use the DEFCON command (restrict server capabilities)
- "massmessage" # message all users on the server
- "metadata" # modify arbitrary metadata on channels and users
# ircd operators
opers:
@ -1087,15 +1088,14 @@ 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 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.
# how many keys can a client subscribe to?
max-subs: 100
# how many keys can a user store about themselves? set to -1 to allow unlimited keys.
max-keys: 1000
# how many keys can be stored per entity?
max-keys: 100
# experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications.

View File

@ -7,6 +7,7 @@ package irc
import (
"fmt"
"iter"
"maps"
"strconv"
"strings"
@ -1676,6 +1677,20 @@ func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) {
return
}
func (channel *Channel) sessionsWithCaps(capabs ...caps.Capability) iter.Seq[*Session] {
return func(yield func(*Session) bool) {
for _, member := range channel.Members() {
for _, sess := range member.Sessions() {
if sess.capabilities.HasAll(capabs...) {
if !yield(sess) {
return
}
}
}
}
}
}
// returns whether the client is visible to unprivileged users in the channel
// (i.e., respecting auditorium mode). note that this assumes that the client
// is a member; if the client is not, it may return true anyway

View File

@ -206,6 +206,10 @@ func (cm *ChannelManager) Cleanup(channel *Channel) {
}
func (cm *ChannelManager) SetRegistered(channelName string, account string) (err error) {
if account == "" {
return errAuthRequired // this is already enforced by ChanServ, but do a final check
}
if cm.server.Defcon() <= 4 {
return errFeatureDisabled
}

View File

@ -728,7 +728,7 @@ type Config struct {
Enabled bool
MaxSubs int `yaml:"max-subs"`
MaxKeys int `yaml:"max-keys"`
MaxValueBytes int `yaml:"max-value-length"` // todo: currently unenforced!!
MaxValueBytes int `yaml:"max-value-length"`
}
WebPush struct {
@ -1649,19 +1649,20 @@ func LoadConfig(filename string) (config *Config, err error) {
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))
// these are required for normal operation, so set sane defaults:
if config.Metadata.MaxSubs == 0 {
config.Metadata.MaxSubs = 10
}
if config.Metadata.MaxKeys > 0 {
metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys))
metadataValues = append(metadataValues, fmt.Sprintf("max-subs=%d", config.Metadata.MaxSubs))
if config.Metadata.MaxKeys == 0 {
config.Metadata.MaxKeys = 10
}
metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys))
// this is not required since we enforce a hardcoded upper bound on key+value
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, ",")
}
config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",")
}
err = config.processExtjwt()

View File

@ -33,6 +33,7 @@ var (
errAccountVerificationInvalidCode = errors.New("Invalid account verification code")
errAccountUpdateFailed = errors.New(`Error while updating your account information`)
errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`)
errAuthRequired = errors.New("You must be logged into an account to do this")
errAuthzidAuthcidMismatch = errors.New(`authcid and authzid must be the same`)
errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`)
errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account")

View File

@ -894,7 +894,7 @@ func (channel *Channel) GetMetadata(key string) (string, bool) {
return val, ok
}
func (channel *Channel) SetMetadata(key string, value string) {
func (channel *Channel) SetMetadata(key string, value string, limit int) (updated bool, err error) {
defer channel.MarkDirty(IncludeAllAttrs)
channel.stateMutex.Lock()
@ -904,7 +904,15 @@ func (channel *Channel) SetMetadata(key string, value string) {
channel.metadata = make(map[string]string)
}
channel.metadata[key] = value
existing, ok := channel.metadata[key]
if !ok && len(channel.metadata) >= limit {
return false, errLimitExceeded
}
updated = !ok || value != existing
if updated {
channel.metadata[key] = value
}
return updated, nil
}
func (channel *Channel) ListMetadata() map[string]string {
@ -914,13 +922,17 @@ func (channel *Channel) ListMetadata() map[string]string {
return maps.Clone(channel.metadata)
}
func (channel *Channel) DeleteMetadata(key string) {
func (channel *Channel) DeleteMetadata(key string) (updated bool) {
defer channel.MarkDirty(IncludeAllAttrs)
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
delete(channel.metadata, key)
_, updated = channel.metadata[key]
if updated {
delete(channel.metadata, key)
}
return updated
}
func (channel *Channel) ClearMetadata() map[string]string {
@ -949,7 +961,7 @@ func (client *Client) GetMetadata(key string) (string, bool) {
return val, ok
}
func (client *Client) SetMetadata(key string, value string) {
func (client *Client) SetMetadata(key string, value string, limit int) (updated bool, err error) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
@ -957,7 +969,15 @@ func (client *Client) SetMetadata(key string, value string) {
client.metadata = make(map[string]string)
}
client.metadata[key] = value
existing, ok := client.metadata[key]
if !ok && len(client.metadata) >= limit {
return false, errLimitExceeded
}
updated = !ok || value != existing
if updated {
client.metadata[key] = value
}
return updated, nil
}
func (client *Client) ListMetadata() map[string]string {
@ -967,11 +987,15 @@ func (client *Client) ListMetadata() map[string]string {
return maps.Clone(client.metadata)
}
func (client *Client) DeleteMetadata(key string) {
func (client *Client) DeleteMetadata(key string) (updated bool) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
delete(client.metadata, key)
_, updated = client.metadata[key]
if updated {
delete(client.metadata, key)
}
return updated
}
func (client *Client) ClearMetadata() map[string]string {

View File

@ -20,6 +20,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/ergochat/irc-go/ircfmt"
"github.com/ergochat/irc-go/ircmsg"
@ -3101,43 +3102,42 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
// METADATA <target> <subcommand> [<and so on>...]
func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) {
originalTarget := msg.Params[0]
target := originalTarget
target := msg.Params[0]
if !server.Config().Metadata.Enabled {
rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", originalTarget, "Metadata is disabled on this server")
config := server.Config()
if !config.Metadata.Enabled {
rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", target, "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"))
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", target, 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 targetObj MetadataHaver
var targetClient *Client
var targetChannel *Channel
if strings.HasPrefix(target, "#") {
targetChannel = server.channels.Get(target)
if targetChannel != nil {
targetObj = targetChannel
target = targetChannel.Name() // canonicalize case
}
} else {
targetClient = server.clients.Get(target)
if targetClient != nil {
targetObj = targetClient
target = targetClient.Nick() // canonicalize case
}
}
var t MetadataHaver
if targetClient != nil {
t = targetClient
}
if targetChannel != nil {
t = targetChannel
}
if t == nil {
invalidTarget()
if targetObj == nil {
rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_TARGET", target, client.t("Invalid metadata target"))
return
}
@ -3151,101 +3151,105 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
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"))
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name"))
return
}
if !metadataCanIEditThisKey(client, target, key) {
if !metadataCanIEditThisKey(client, targetObj, key) {
noKeyPerms(key)
return
}
if len(msg.Params) > 3 {
value := msg.Params[3]
const maxCombinedLen = 350
if len(key)+len(value) > maxCombinedLen {
if !globalUtf8EnforcementSetting && !utf8.ValidString(value) {
rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t("METADATA values must be UTF-8"))
return
}
if len(key)+len(value) > maxCombinedMetadataLenBytes ||
(config.Metadata.MaxValueBytes > 0 && len(value) > config.Metadata.MaxValueBytes) {
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"))
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"))
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)
// echo the value to the client whether or not there was a real update
rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), target, key, "*", value)
if updated {
notifySubscribers(server, rb.session, targetObj, target, key, value)
}
} else {
server.logger.Debug("metadata", "deleting", key, "on", target)
t.DeleteMetadata(key)
notifySubscribers(server, rb.session, target, key, "")
if updated := targetObj.DeleteMetadata(key); updated {
notifySubscribers(server, rb.session, targetObj, target, key, "")
}
// acknowledge to the client whether or not there was a real update
rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key deleted"))
}
case "get":
if !metadataCanISeeThisTarget(client, targetObj) {
noKeyPerms("*")
return
}
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"))
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name"))
continue
}
val, ok := t.GetMetadata(key)
val, ok := targetObj.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)
rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), target, 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)
}
playMetadataList(rb, client.Nick(), target, targetObj.ListMetadata())
case "clear":
if !metadataCanIEditThisTarget(client, target) {
invalidTarget()
if !metadataCanIEditThisTarget(client, targetObj) {
noKeyPerms("*")
return
}
values := t.ClearMetadata()
values := targetObj.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)
rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), target, key, visibility, val)
}
case "sub":
keys := msg.Params[2:]
server.logger.Debug("metadata", client.nick, "has subscrumbled to", strings.Join(keys, ", "))
for _, key := range keys {
if metadataKeyIsEvil(key) {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", 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", bad, client.t("Too many subscriptions"))
rb.Add(nil, server.name, "FAIL", "METADATA", "TOO_MANY_SUBS", utils.SafeErrorParam(bad), client.t("Too many subscriptions"))
}
lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(client.Nick()) - 10
@ -3258,7 +3262,6 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
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
@ -3288,12 +3291,22 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
}
default:
rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", msg.Params[1], client.t("Invalid subcommand"))
rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", utils.SafeErrorParam(msg.Params[1]), client.t("Invalid subcommand"))
}
return
}
func playMetadataList(rb *ResponseBuffer, nick, target string, values map[string]string) {
batchId := rb.StartNestedBatch("metadata")
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)
}
}
// REHASH
func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
nick := client.Nick()

View File

@ -342,7 +342,7 @@ specification.`,
},
"metadata": {
text: `METADATA <target> <subcommand> [<everything else>...]
Retrieve and meddle with metadata for the given target.
Have a look at https://ircv3.net/specs/extensions/metadata for interesting technical information.`,
},

View File

@ -2,12 +2,17 @@ package irc
import (
"errors"
"iter"
"maps"
"regexp"
"strings"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
)
const (
// metadata key + value need to be relayable on a single IRC RPL_KEYVALUE line
maxCombinedMetadataLenBytes = 350
)
var (
@ -16,42 +21,39 @@ var (
)
type MetadataHaver = interface {
SetMetadata(key string, value string)
SetMetadata(key string, value string, limit int) (updated bool, err error)
GetMetadata(key string) (string, bool)
DeleteMetadata(key string)
DeleteMetadata(key string) (updated bool)
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)
func notifySubscribers(server *Server, session *Session, targetObj MetadataHaver, targetName, key, value string) {
var recipientSessions iter.Seq[*Session]
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)
}
}
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
}
// don't notify the session that made the change
notify.Remove(session)
broadcastMetadataUpdate(server, recipientSessions, session, targetName, key, value)
}
for s := range notify {
if !s.isSubscribedTo(key) {
func broadcastMetadataUpdate(server *Server, sessions iter.Seq[*Session], originator *Session, target, key, value string) {
for s := range sessions {
// don't notify the session that made the change
if s == originator || !s.isSubscribedTo(key) {
continue
}
@ -64,42 +66,38 @@ 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)
subs := rb.session.MetadataSubscriptions()
values := target.ListMetadata()
for k, v := range values {
if rb.session.isSubscribedTo(k) {
if subs.Has(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
}
func syncChannelMetadata(server *Server, rb *ResponseBuffer, channel *Channel) {
batchId := rb.StartNestedBatch("metadata")
defer rb.EndNestedBatch(batchId)
values := target.ListMetadata()
subs := rb.session.MetadataSubscriptions()
chname := channel.Name()
values := channel.ListMetadata()
for k, v := range values {
if rb.session.isSubscribedTo(k) {
if subs.Has(k) {
visibility := "*"
rb.Add(nil, server.name, "METADATA", target.Name(), k, visibility, v)
rb.Add(nil, server.name, "METADATA", chname, k, visibility, v)
}
}
for _, client := range target.Members() {
for _, client := range channel.Members() {
values := client.ListMetadata()
for k, v := range values {
if rb.session.isSubscribedTo(k) {
if subs.Has(k) {
visibility := "*"
rb.Add(nil, server.name, "METADATA", client.Nick(), k, visibility, v)
}
@ -110,55 +108,34 @@ func syncChannelMetadata(server *Server, rb *ResponseBuffer, target *Channel) {
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 metadataCanIEditThisKey(client *Client, targetObj MetadataHaver, key string) bool {
// no key-specific logic as yet
return metadataCanIEditThisTarget(client, targetObj)
}
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
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
}
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
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
}
// check if the user is in the channel
channel := client.server.channels.Get(target)
if channel != nil && !channel.hasClient(client) {
return false
}
return true
}

View File

@ -496,6 +496,9 @@ func (server *Server) playRegistrationBurst(session *Session) {
if !(rb.session.capabilities.Has(caps.ExtendedISupport) && rb.session.isupportSentPrereg) {
server.RplISupport(c, rb)
}
if session.capabilities.Has(caps.Metadata) && c.AlwaysOn() {
playMetadataList(rb, d.nick, d.nick, c.ListMetadata())
}
if d.account != "" && session.capabilities.Has(caps.Persistence) {
reportPersistenceStatus(c, rb, false)
}

@ -1 +1 @@
Subproject commit 15c847ebb19b1c434347236296cbc4d34ba7c4b2
Subproject commit a3c2110be24ad53ec4babe0facdc25aa2151d3e5

View File

@ -695,6 +695,7 @@ oper-classes:
- "history" # modify or delete history messages
- "defcon" # use the DEFCON command (restrict server capabilities)
- "massmessage" # message all users on the server
- "metadata" # modify arbitrary metadata on channels and users
# ircd operators
opers:
@ -1058,12 +1059,14 @@ 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 support for setting key/value data on channels and nicknames.
metadata:
# can clients use the metadata command?
# can clients store metadata?
enabled: true
# how many keys can a client subscribe to?
max-subs: 1000
max-subs: 100
# how many keys can be stored per entity?
max-keys: 100
# experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications.