3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-06-17 04:07:29 +02:00

metadata-2 (#2273)

Initial implementation of draft/metadata-2
This commit is contained in:
thatcher-gaming 2025-06-15 09:06:45 +01:00 committed by GitHub
parent 0f5603eca2
commit 4dcbc48159
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 660 additions and 3 deletions

View File

@ -1087,6 +1087,16 @@ history:
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true 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 # experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications. # 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 # DO NOT enable if you are running a Tor or I2P hidden service (i.e. one

View File

@ -237,6 +237,13 @@ CAPDEFS = [
url="https://github.com/ircv3/ircv3-specifications/pull/471", url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="Soju/Goguma vendor", standard="Soju/Goguma vendor",
), ),
CapDef(
identifier="Metadata",
name="draft/metadata-2",
url="https://ircv3.net/specs/extensions/metadata",
standard="draft IRCv3",
),
] ]
def validate_defs(): def validate_defs():

View File

@ -7,7 +7,7 @@ package caps
const ( const (
// number of recognized capabilities: // number of recognized capabilities:
numCapabs = 37 numCapabs = 38
// length of the uint32 array that represents the bitset: // length of the uint32 array that represents the bitset:
bitsetLen = 2 bitsetLen = 2
) )
@ -65,6 +65,10 @@ const (
// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md // https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
MessageRedaction Capability = iota 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": // Multiline is the proposed IRCv3 capability named "draft/multiline":
// https://github.com/ircv3/ircv3-specifications/pull/398 // https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota Multiline Capability = iota
@ -178,6 +182,7 @@ var (
"draft/extended-isupport", "draft/extended-isupport",
"draft/languages", "draft/languages",
"draft/message-redaction", "draft/message-redaction",
"draft/metadata-2",
"draft/multiline", "draft/multiline",
"draft/no-implicit-names", "draft/no-implicit-names",
"draft/persistence", "draft/persistence",

View File

@ -55,6 +55,7 @@ type Channel struct {
dirtyBits uint dirtyBits uint
settings ChannelSettings settings ChannelSettings
uuid utils.UUID uuid utils.UUID
metadata map[string]string
// these caches are paired to allow iteration over channel members without holding the lock // these caches are paired to allow iteration over channel members without holding the lock
membersCache []*Client membersCache []*Client
memberDataCache []*memberData memberDataCache []*memberData
@ -126,6 +127,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
channel.userLimit = chanReg.UserLimit channel.userLimit = chanReg.UserLimit
channel.settings = chanReg.Settings channel.settings = chanReg.Settings
channel.forward = chanReg.Forward channel.forward = chanReg.Forward
channel.metadata = chanReg.Metadata
for _, mode := range chanReg.Modes { for _, mode := range chanReg.Modes {
channel.flags.SetMode(mode, true) channel.flags.SetMode(mode, true)
@ -163,6 +165,7 @@ func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
info.AccountToUMode = maps.Clone(channel.accountToUMode) info.AccountToUMode = maps.Clone(channel.accountToUMode)
info.Settings = channel.settings info.Settings = channel.settings
info.Metadata = channel.metadata
return 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)) 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 { if rb.session.client == client {
// don't send topic and names for a SAJOIN of a different client // don't send topic and names for a SAJOIN of a different client
channel.SendTopic(client, rb, false) channel.SendTopic(client, rb, false)

View File

@ -63,6 +63,8 @@ type RegisteredChannel struct {
Invites map[string]MaskInfo Invites map[string]MaskInfo
// Settings are the chanserv-modifiable settings // Settings are the chanserv-modifiable settings
Settings ChannelSettings Settings ChannelSettings
// Metadata set using the METADATA command
Metadata map[string]string
} }
func (r *RegisteredChannel) Serialize() ([]byte, error) { func (r *RegisteredChannel) Serialize() ([]byte, error) {

View File

@ -131,6 +131,7 @@ type Client struct {
clearablePushMessages map[string]time.Time clearablePushMessages map[string]time.Time
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0 pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
pushQueue pushQueue pushQueue pushQueue
metadata map[string]string
} }
type saslStatus struct { type saslStatus struct {
@ -214,6 +215,8 @@ type Session struct {
batch MultilineBatch batch MultilineBatch
webPushEndpoint string // goroutine-local: web push endpoint registered by the current session 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. // 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.nickCasefolded = nickCasefolded
client.skeleton = skeleton client.skeleton = skeleton
client.updateNickMaskNoMutex() client.updateNickMaskNoMutex()
return true return true
} }

View File

@ -209,6 +209,10 @@ func init() {
handler: markReadHandler, handler: markReadHandler,
minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS
}, },
"METADATA": {
handler: metadataHandler,
minParams: 2,
},
"MODE": { "MODE": {
handler: modeHandler, handler: modeHandler,
minParams: 1, minParams: 1,

View File

@ -723,6 +723,14 @@ type Config struct {
} `yaml:"tagmsg-storage"` } `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 { WebPush struct {
Enabled bool Enabled bool
Timeout time.Duration 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() err = config.processExtjwt()
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"net" "net"
"slices"
"time" "time"
"github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/caps"
@ -797,10 +798,12 @@ func (channel *Channel) Settings() (result ChannelSettings) {
} }
func (channel *Channel) SetSettings(settings ChannelSettings) { func (channel *Channel) SetSettings(settings ChannelSettings) {
defer channel.MarkDirty(IncludeSettings)
channel.stateMutex.Lock() channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
channel.settings = settings channel.settings = settings
channel.stateMutex.Unlock()
channel.MarkDirty(IncludeSettings)
} }
func (channel *Channel) setForward(forward string) { func (channel *Channel) setForward(forward string) {
@ -827,3 +830,163 @@ func (channel *Channel) UUID() utils.UUID {
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()
return channel.uuid 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)
}

View File

@ -9,11 +9,13 @@ package irc
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"maps"
"net" "net"
"os" "os"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"runtime/pprof" "runtime/pprof"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -3097,6 +3099,201 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
return return
} }
// 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
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 // REHASH
func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
nick := client.Nick() nick := client.Nick()

View File

@ -339,6 +339,12 @@ command is processed by that server.`,
MARKREAD updates an IRCv3 read message marker. It is not intended for use by 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 end users. For more details, see the latest draft of the read-marker
specification.`, 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.`,
}, },
"mode": { "mode": {
text: `MODE <target> [<modestring> [<mode arguments>...]] text: `MODE <target> [<modestring> [<mode arguments>...]]

164
irc/metadata.go Normal file
View File

@ -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
}

21
irc/metadata_test.go Normal file
View File

@ -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)
}
}
}

View File

@ -183,6 +183,11 @@ const (
RPL_MONLIST = "732" RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733" RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734" ERR_MONLISTFULL = "734"
RPL_KEYVALUE = "761" // metadata numerics
RPL_KEYNOTSET = "766"
RPL_METADATASUBOK = "770"
RPL_METADATAUNSUBOK = "771"
RPL_METADATASUBS = "772"
RPL_LOGGEDIN = "900" RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901" RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902" ERR_NICKLOCKED = "902"

28
irc/utils/chunks.go Normal file
View File

@ -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
}

View File

@ -1058,6 +1058,13 @@ history:
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true 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 # experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications. # 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 # DO NOT enable if you are running a Tor or I2P hidden service (i.e. one