Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions gencapdefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@
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():
Expand Down
7 changes: 6 additions & 1 deletion irc/caps/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -178,6 +182,7 @@ var (
"draft/extended-isupport",
"draft/languages",
"draft/message-redaction",
"draft/metadata-2",
"draft/multiline",
"draft/no-implicit-names",
"draft/persistence",
Expand Down
7 changes: 7 additions & 0 deletions irc/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions irc/channelreg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions irc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1129,6 +1132,7 @@ func (client *Client) SetNick(nick, nickCasefolded, skeleton string) (success bo
client.nickCasefolded = nickCasefolded
client.skeleton = skeleton
client.updateNickMaskNoMutex()

return true
}

Expand Down
4 changes: 4 additions & 0 deletions irc/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions irc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
167 changes: 165 additions & 2 deletions irc/getters.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"net"
"slices"
"time"

"github.com/ergochat/ergo/irc/caps"
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Comment on lines +903 to +905
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete as per above


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