diff --git a/irc/caps/constants.go b/irc/caps/constants.go index 00e60a74..314abb68 100644 --- a/irc/caps/constants.go +++ b/irc/caps/constants.go @@ -36,6 +36,8 @@ const ( MaxLine Capability = "oragono.io/maxline" // MessageTags is this draft IRCv3 capability: http://ircv3.net/specs/core/message-tags-3.3.html MessageTags Capability = "draft/message-tags-0.2" + // Metadata is this draft IRCv3 capability: https://github.com/jwheare/ircv3-specifications/blob/metadata/core/metadata-3.2.md + Metadata Capability = "draft/metadata" // MultiPrefix is this IRCv3 capability: http://ircv3.net/specs/extensions/multi-prefix-3.1.html MultiPrefix Capability = "multi-prefix" // Rename is this proposed capability: https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md diff --git a/irc/channel.go b/irc/channel.go index b082926c..288b0456 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -25,6 +25,7 @@ type Channel struct { members MemberSet membersCache []*Client // allow iteration over channel members without holding the lock membersCacheMutex sync.Mutex // tier 2; see `regenerateMembersCache` + metadata *MetadataManager name string nameCasefolded string server *Server @@ -56,6 +57,7 @@ func NewChannel(s *Server, name string, addDefaultModes bool, regInfo *Registere modes.InviteMask: NewUserMaskSet(), }, members: make(MemberSet), + metadata: NewMetadataManager(), name: name, nameCasefolded: casefoldedName, server: s, diff --git a/irc/client.go b/irc/client.go index d309102a..79ab0cb0 100644 --- a/irc/client.go +++ b/irc/client.go @@ -60,6 +60,7 @@ type Client struct { languages []string maxlenTags uint32 maxlenRest uint32 + metadata *MetadataManager nick string nickCasefolded string nickMaskCasefolded string @@ -100,6 +101,7 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) *Client { channels: make(ChannelSet), ctime: now, flags: make(map[modes.Mode]bool), + metadata: NewMetadataManager(), server: server, socket: &socket, nick: "*", // * is used until actual nick is given diff --git a/irc/commands.go b/irc/commands.go index 9970af68..9e1cf498 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -161,6 +161,10 @@ func init() { handler: lusersHandler, minParams: 0, }, + "METADATA": { + handler: metadataHandler, + minParams: 2, + }, "MODE": { handler: modeHandler, minParams: 1, diff --git a/irc/errors.go b/irc/errors.go index 9354d59a..32edff71 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -34,6 +34,7 @@ var ( errNoSuchChannel = errors.New("No such channel") errRenamePrivsNeeded = errors.New("Only chanops can rename channels") errSaslFail = errors.New("SASL failed") + errTooManyKeys = errors.New("Too many metadata keys") ) // Socket Errors diff --git a/irc/handlers.go b/irc/handlers.go index 5ab121ee..fdd68973 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1311,6 +1311,300 @@ func lusersHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re return false } +// METADATA * SUB { } +// METADATA * SUBS +// METADATA * UNSUB { } +// METADATA CLEAR +// METADATA GET { } +// METADATA LIST +// METADATA SET [] +// METADATA SYNC +func metadataHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + handler, exists := metadataSubcommands[strings.ToLower(msg.Params[1])] + + if !exists { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "METADATA", client.t("Unknown subcommand")) + return false + } + + return handler(server, client, msg, rb) +} + +// METADATA CLEAR +func metadataClearHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + var mm *MetadataManager + + targetString := msg.Params[0] + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + return false + } + if !channel.ClientIsAtLeast(client, modes.ChannelOperator) { + rb.Add(nil, server.name, ERR_CHANOPRIVSNEEDED, client.nick, targetString, client.t("You're not a channel operator")) + return false + } + mm = channel.metadata + } else { + target, err = CasefoldName(targetString) + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + } else { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "METADATA", client.t("Unknown error")) + } + return false + } + if user != client { + rb.Add(nil, server.name, ERR_KEYNOPERMISSION, client.nick, target, "*", client.t("Permission denied")) + return false + } + mm = user.metadata + } + + for _, key := range mm.Clear() { + rb.Add(nil, server.name, RPL_KEYVALUE, client.nick, target, key, "*") + } + rb.Add(nil, server.name, RPL_METADATAEND, client.nick, client.t("End of metadata")) + + return false +} + +// METADATA GET { } +func metadataGetHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + if len(msg.Params) < 3 { + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) + return false + } + + var mm *MetadataManager + + targetString := msg.Params[0] + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + return false + } + if !channel.hasClient(client) { + rb.Add(nil, server.name, ERR_KEYNOPERMISSION, client.nick, target, client.t("You're not on that channel!")) + return false + } + mm = channel.metadata + } else { + target, err = CasefoldName(targetString) + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + } else { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "METADATA", client.t("Unknown error")) + } + return false + } + mm = user.metadata + } + + for i, key := range msg.Params { + // only process actual keys, skip target and (sub)command name + if 1 < i { + key = strings.TrimSpace(strings.ToLower(key)) + if !metadataKeyValid(key) { + rb.Add(nil, server.name, ERR_KEYINVALID, client.nick, key) + continue + } + value, exists := mm.Get(key) + if !exists { + rb.Add(nil, server.name, ERR_NOMATCHINGKEY, client.nick, target, key, client.t("No matching key")) + continue + } + + rb.Add(nil, server.name, RPL_KEYVALUE, client.nick, target, key, "*", value) + } + } + return false +} + +// METADATA LIST +func metadataListHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + var mm *MetadataManager + + targetString := msg.Params[0] + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + return false + } + if !channel.hasClient(client) { + rb.Add(nil, server.name, ERR_KEYNOPERMISSION, client.nick, target, client.t("You're not on that channel!")) + return false + } + mm = channel.metadata + } else { + target, err = CasefoldName(targetString) + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + } else { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "METADATA", client.t("Unknown error")) + } + return false + } + mm = user.metadata + } + + for key, value := range mm.List() { + rb.Add(nil, server.name, RPL_KEYVALUE, client.nick, target, key, "*", value) + } + rb.Add(nil, server.name, RPL_METADATAEND, client.nick, client.t("End of metadata")) + + return false +} + +// METADATA SET [] +func metadataSetHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + if len(msg.Params) < 3 { + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) + return false + } + + // retrieve key/value + key := strings.TrimSpace(strings.ToLower(msg.Params[2])) + if !metadataKeyValid(key) { + rb.Add(nil, server.name, ERR_KEYINVALID, client.nick, key) + return false + } + + var settingValue bool + var value string + if 3 < len(msg.Params) { + settingValue = true + value = msg.Params[3] + } + + // work on target + targetString := msg.Params[0] + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + return false + } + if !channel.ClientIsAtLeast(client, modes.ChannelOperator) { + rb.Add(nil, server.name, ERR_CHANOPRIVSNEEDED, client.nick, targetString, client.t("You're not a channel operator")) + return false + } + if settingValue { + err := channel.metadata.Set(key, value, server.MetadataKeysLimit()) + if err == errTooManyKeys { + rb.Add(nil, server.name, ERR_METADATALIMIT, client.nick, target, client.t("Metadata limit reached")) + return false + } + } else { + channel.metadata.Delete(key) + } + } else { + target, err = CasefoldName(targetString) + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + } else { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "METADATA", client.t("Unknown error")) + } + return false + } + if user != client { + rb.Add(nil, server.name, ERR_KEYNOPERMISSION, client.nick, target, "*", client.t("Permission denied")) + return false + } + if settingValue { + err := user.metadata.Set(key, value, server.MetadataKeysLimit()) + if err == errTooManyKeys { + rb.Add(nil, server.name, ERR_METADATALIMIT, client.nick, target, client.t("Metadata limit reached")) + return false + } + } else { + user.metadata.Delete(key) + } + } + + if settingValue { + rb.Add(nil, server.name, RPL_KEYVALUE, client.nick, target, key, "*", value) + } else { + rb.Add(nil, server.name, RPL_KEYVALUE, client.nick, target, key, "*") + } + rb.Add(nil, server.name, RPL_METADATAEND, client.nick, client.t("End of metadata")) + + return false +} + +func metadataSubHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + client.Notice("METADATA SUB not yet implemented") + return false +} + +func metadataSubsHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + client.Notice("METADATA SUBS not yet implemented") + return false +} + +// METADATA SYNC +//TODO(dan): SYNC also returns e.g. metadata keys of friends in the given channel, etc. +//Note: +// One difference with list is that you can’t get a whole channel full of members metadata with it. +// With sync you can, you get targets you didn’t explicitly request. It’s more of a trigger than a request. +// And it’s mainly only useful for the delayed synchronisation function. +func metadataSyncHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + var mm *MetadataManager + + targetString := msg.Params[0] + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + return false + } + if !channel.hasClient(client) { + rb.Add(nil, server.name, ERR_KEYNOPERMISSION, client.nick, target, client.t("You're not on that channel!")) + return false + } + mm = channel.metadata + } else { + target, err = CasefoldName(targetString) + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + rb.Add(nil, server.name, ERR_TARGETINVALID, client.nick, target, client.t("Invalid metadata target")) + } else { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "METADATA", client.t("Unknown error")) + } + return false + } + mm = user.metadata + } + + for key, value := range mm.List() { + rb.Add(nil, server.name, "METADATA", target, key, "*", value) + } + + return false +} + +func metadataUnsubHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + client.Notice("METADATA UNSUB not yet implemented") + return false +} + // MODE [ [...]] func modeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { _, errChan := CasefoldChannel(msg.Params[0]) @@ -1448,7 +1742,7 @@ func monitorHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R handler, exists := monitorSubcommands[strings.ToLower(msg.Params[0])] if !exists { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "MONITOR", msg.Params[0], client.t("Unknown subcommand")) + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "MONITOR", client.t("Unknown subcommand")) return false } diff --git a/irc/help.go b/irc/help.go index 9c81e99b..ca80410d 100644 --- a/irc/help.go +++ b/irc/help.go @@ -269,6 +269,13 @@ channels). s modify how the channels are selected.`, Shows statistics about the size of the network. If is given, only returns stats for servers matching the given mask. If is given, the command is processed by that server.`, + }, + "metadata": { + text: `METADATA [...] + +Sets, removes and displays metadata about clients and channels. For more +specific information, see the spec here: +https://github.com/jwheare/ircv3-specifications/blob/metadata/core/metadata-3.2.md`, }, "mode": { text: `MODE [ [...]] diff --git a/irc/metadata.go b/irc/metadata.go new file mode 100644 index 00000000..5c1740ef --- /dev/null +++ b/irc/metadata.go @@ -0,0 +1,192 @@ +// Copyright (c) 2018 Daniel Oaks +// released under the MIT license + +package irc + +import ( + "sync" + + "github.com/goshuirc/irc-go/ircmsg" +) + +var ( + //TODO(dan): temporary hardcoded limits, make these configurable instead. + metadataKeysLimit = 20 + metadataSubsLimit = 20 +) + +// MetadataKeysLimit returns how many metadata keys can be set on each client/channel. +//TODO(dan): have this be configurable in the config file instead. +func (server *Server) MetadataKeysLimit() int { + return metadataKeysLimit +} + +// MetadataSubsLimit returns how many metadata keys can be subscribed to. +//TODO(dan): have this be configurable in the config file instead. +func (server *Server) MetadataSubsLimit() int { + return metadataSubsLimit +} + +// MetadataManager manages metadata for a client or channel. +type MetadataManager struct { + sync.RWMutex + // keyvals holds our values internally. + keyvals map[string]string +} + +// NewMetadataManager returns a new MetadataManager. +func NewMetadataManager() *MetadataManager { + var mm MetadataManager + mm.keyvals = make(map[string]string) + return &mm +} + +// Clear deletes all keys, returning a list of the deleted keys. +func (mm *MetadataManager) Clear() []string { + var keys []string + + mm.Lock() + defer mm.Unlock() + + for key := range mm.keyvals { + keys = append(keys, key) + delete(mm.keyvals, key) + } + return keys +} + +// List returns all keys and values. +func (mm *MetadataManager) List() map[string]string { + data := make(map[string]string) + + mm.RLock() + defer mm.RUnlock() + + for key, value := range mm.keyvals { + data[key] = value + } + return data +} + +// Get returns the value of a single key. +func (mm *MetadataManager) Get(key string) (string, bool) { + mm.RLock() + defer mm.RUnlock() + + value, exists := mm.keyvals[key] + return value, exists +} + +// Set sets the value of the given key. A limit of -1 means ignore any limits. +func (mm *MetadataManager) Set(key, value string, limit int) error { + mm.Lock() + defer mm.Unlock() + + _, currentlyExists := mm.keyvals[key] + if limit != -1 && !currentlyExists && limit < len(mm.keyvals)+1 { + return errTooManyKeys + } + + mm.keyvals[key] = value + + return nil +} + +// Delete removes the given key. +func (mm *MetadataManager) Delete(key string) { + mm.Lock() + defer mm.Unlock() + + delete(mm.keyvals, key) +} + +// MetadataSubsManager manages metadata key subscriptions. +type MetadataSubsManager struct { + sync.RWMutex + // watchedKeys holds our list of watched (sub'd) keys. + watchedKeys map[string]bool +} + +// NewMetadataSubsManager returns a new MetadataSubsManager. +func NewMetadataSubsManager() *MetadataSubsManager { + var msm MetadataSubsManager + msm.watchedKeys = make(map[string]bool) + return &msm +} + +// Sub subscribes to the given keys. +func (msm *MetadataSubsManager) Sub(key ...string) { + msm.Lock() + defer msm.Unlock() + + for _, k := range key { + msm.watchedKeys[k] = true + } +} + +// Unsub ubsubscribes from the given keys. +func (msm *MetadataSubsManager) Unsub(key ...string) { + msm.Lock() + defer msm.Unlock() + + for _, k := range key { + delete(msm.watchedKeys, k) + } +} + +// List returns a list of the currently-subbed keys. +func (msm *MetadataSubsManager) List() []string { + var keys []string + + msm.RLock() + defer msm.RUnlock() + + for k := range msm.watchedKeys { + keys = append(keys, k) + } + + return keys +} + +var ( + metadataValidChars = map[rune]bool{ + 'a': true, 'b': true, 'c': true, 'd': true, 'e': true, 'f': true, 'g': true, + 'h': true, 'i': true, 'j': true, 'k': true, 'l': true, 'm': true, 'o': true, + 'p': true, 'q': true, 'r': true, 's': true, 't': true, 'u': true, 'v': true, + 'w': true, 'x': true, 'y': true, 'z': true, '0': true, '1': true, '2': true, + '3': true, '4': true, '5': true, '6': true, '7': true, '8': true, '9': true, + '_': true, '-': true, '.': true, ':': true, + } +) + +// metadataKeyValid returns true if the given key is valid. +func metadataKeyValid(key string) bool { + // key length + if len(key) < 1 { + return false + } + // invalid first character for a key + if key[0] == ':' { + return false + } + // name characters + for _, cha := range []rune(key) { + if metadataValidChars[rune(cha)] == false { + return false + } + } + return true +} + +var ( + metadataSubcommands = map[string]func(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool{ + "clear": metadataClearHandler, + "get": metadataGetHandler, + "list": metadataListHandler, + "set": metadataSetHandler, + "sub": metadataSubHandler, + "subs": metadataSubsHandler, + "sync": metadataSyncHandler, + "unsub": metadataUnsubHandler, + } +) diff --git a/irc/numerics.go b/irc/numerics.go index ec8e83cc..9996f880 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -173,6 +173,21 @@ const ( RPL_MONLIST = "732" RPL_ENDOFMONLIST = "733" ERR_MONLISTFULL = "734" + RPL_WHOISKEYVALUE = "760" + RPL_KEYVALUE = "761" + RPL_METADATAEND = "762" + ERR_METADATALIMIT = "764" + ERR_TARGETINVALID = "765" + ERR_NOMATCHINGKEY = "766" + ERR_KEYINVALID = "767" + ERR_KEYNOTSET = "768" + ERR_KEYNOPERMISSION = "769" + RPL_METADATASUBOK = "770" + RPL_METADATAUNSUBOK = "771" + RPL_METADATASUBS = "772" + ERR_METADATATOOMANYSUBS = "773" + ERR_METADATASYNCLATER = "774" + ERR_METADATARATELIMIT = "775" RPL_LOGGEDIN = "900" RPL_LOGGEDOUT = "901" ERR_NICKLOCKED = "902" diff --git a/irc/server.go b/irc/server.go index e6a5570b..28e0b01b 100644 --- a/irc/server.go +++ b/irc/server.go @@ -51,7 +51,7 @@ var ( // SupportedCapabilities are the caps we advertise. // MaxLine, SASL and STS are set during server startup. - SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.Batch, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.LabeledResponse, caps.Languages, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.Resume, caps.ServerTime, caps.UserhostInNames) + SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.Batch, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.LabeledResponse, caps.Languages, caps.MessageTags, caps.Metadata, caps.MultiPrefix, caps.Rename, caps.Resume, caps.ServerTime, caps.UserhostInNames) // CapValues are the actual values we advertise to v3.2 clients. // actual values are set during server startup. @@ -835,6 +835,9 @@ func (server *Server) applyConfig(config *Config, initial bool) error { server.languages = lm + // Metadata + CapValues.Set(caps.Metadata, "maxsub=10") + // SASL oldAccountConfig := server.AccountConfig() authPreviouslyEnabled := oldAccountConfig != nil && oldAccountConfig.AuthenticationEnabled