mirror of
https://github.com/ergochat/ergo.git
synced 2025-06-17 04:07:29 +02:00
parent
0f5603eca2
commit
4dcbc48159
10
default.yaml
10
default.yaml
@ -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
|
||||||
|
@ -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():
|
||||||
|
@ -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",
|
||||||
|
@ -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)
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
167
irc/getters.go
167
irc/getters.go
@ -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)
|
||||||
|
}
|
||||||
|
197
irc/handlers.go
197
irc/handlers.go
@ -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()
|
||||||
|
@ -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
164
irc/metadata.go
Normal 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
21
irc/metadata_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
28
irc/utils/chunks.go
Normal 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
|
||||||
|
}
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user