From 07865b8f63e255660dde6e5186f6c00986b52bf8 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Mon, 16 Dec 2019 19:50:15 -0500 Subject: [PATCH] chanserv enhancements and miscellaneous fixes * Fix #684 * Fix #683 * Add `CHANSERV CLEAR` * Allow mode changes from channel founders even when they aren't joined * Operators with the chanreg capability are exempt from max-channels-per-account * Small fixes and cleanup --- irc/accounts.go | 2 +- irc/channel.go | 127 ++++++++++++++++--- irc/channelmanager.go | 54 ++++++++ irc/channelreg.go | 110 +++++++++++++++-- irc/chanserv.go | 280 +++++++++++++++++++++++++++++++++++++++++- irc/client.go | 4 +- irc/errors.go | 1 + irc/handlers.go | 19 ++- irc/hostserv.go | 4 +- irc/modes.go | 10 +- irc/nickserv.go | 12 +- 11 files changed, 566 insertions(+), 57 deletions(-) diff --git a/irc/accounts.go b/irc/accounts.go index 3b13bcca..4752c62f 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -490,7 +490,7 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccou fmt.Sprintf(client.t("Account: %s"), casefoldedAccount) + "\r\n", fmt.Sprintf(client.t("Verification code: %s"), code) + "\r\n", "\r\n", - client.t("To verify your account, issue one of these commands:") + "\r\n", + client.t("To verify your account, issue the following command:") + "\r\n", fmt.Sprintf("/MSG NickServ VERIFY %s %s", casefoldedAccount, code) + "\r\n", } diff --git a/irc/channel.go b/irc/channel.go index 0536e14b..3ae6b9ea 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -37,6 +37,7 @@ type Channel struct { createdTime time.Time registeredFounder string registeredTime time.Time + transferPendingTo string topic string topicSetBy string topicSetTime time.Time @@ -59,22 +60,17 @@ func NewChannel(s *Server, name string, registered bool) *Channel { return nil } + config := s.Config() + channel := &Channel{ - createdTime: time.Now().UTC(), // may be overwritten by applyRegInfo - lists: map[modes.Mode]*UserMaskSet{ - modes.BanMask: NewUserMaskSet(), - modes.ExceptMask: NewUserMaskSet(), - modes.InviteMask: NewUserMaskSet(), - }, + createdTime: time.Now().UTC(), // may be overwritten by applyRegInfo members: make(MemberSet), name: name, nameCasefolded: casefoldedName, server: s, - accountToUMode: make(map[string]modes.Mode), } - config := s.Config() - + channel.initializeLists() channel.writerSemaphore.Initialize(1) channel.history.Initialize(config.History.ChannelLength, config.History.AutoresizeWindow) @@ -89,6 +85,15 @@ func NewChannel(s *Server, name string, registered bool) *Channel { return channel } +func (channel *Channel) initializeLists() { + channel.lists = map[modes.Mode]*UserMaskSet{ + modes.BanMask: NewUserMaskSet(), + modes.ExceptMask: NewUserMaskSet(), + modes.InviteMask: NewUserMaskSet(), + } + channel.accountToUMode = make(map[string]modes.Mode) +} + // EnsureLoaded blocks until the channel's registration info has been loaded // from the database. func (channel *Channel) EnsureLoaded() { @@ -303,6 +308,18 @@ func (channel *Channel) SetUnregistered(expectedFounder string) { channel.accountToUMode = make(map[string]modes.Mode) } +// implements `CHANSERV CLEAR #chan ACCESS` (resets bans, invites, excepts, and amodes) +func (channel *Channel) resetAccess() { + defer channel.MarkDirty(IncludeLists) + + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + channel.initializeLists() + if channel.registeredFounder != "" { + channel.accountToUMode[channel.registeredFounder] = modes.ChannelFounder + } +} + // IsRegistered returns whether the channel is registered. func (channel *Channel) IsRegistered() bool { channel.stateMutex.RLock() @@ -310,6 +327,78 @@ func (channel *Channel) IsRegistered() bool { return channel.registeredFounder != "" } +type channelTransferStatus uint + +const ( + channelTransferComplete channelTransferStatus = iota + channelTransferPending + channelTransferCancelled + channelTransferFailed +) + +// Transfer transfers ownership of a registered channel to a different account +func (channel *Channel) Transfer(client *Client, target string, hasPrivs bool) (status channelTransferStatus, err error) { + status = channelTransferFailed + defer func() { + if status == channelTransferComplete && err == nil { + channel.Store(IncludeAllChannelAttrs) + } + }() + + cftarget, err := CasefoldName(target) + if err != nil { + err = errAccountDoesNotExist + return + } + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + if channel.registeredFounder == "" { + err = errChannelNotOwnedByAccount + return + } + if hasPrivs { + channel.transferOwnership(cftarget) + return channelTransferComplete, nil + } else { + if channel.registeredFounder == cftarget { + // transferring back to yourself cancels a pending transfer + channel.transferPendingTo = "" + return channelTransferCancelled, nil + } else { + channel.transferPendingTo = cftarget + return channelTransferPending, nil + } + } +} + +func (channel *Channel) transferOwnership(newOwner string) { + delete(channel.accountToUMode, channel.registeredFounder) + channel.registeredFounder = newOwner + channel.accountToUMode[channel.registeredFounder] = modes.ChannelFounder + channel.transferPendingTo = "" +} + +// AcceptTransfer implements `CS TRANSFER #chan ACCEPT` +func (channel *Channel) AcceptTransfer(client *Client) (err error) { + defer func() { + if err == nil { + channel.Store(IncludeAllChannelAttrs) + } + }() + + account := client.Account() + if account == "" { + return errAccountNotLoggedIn + } + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + if account != channel.transferPendingTo { + return errChannelTransferNotOffered + } + channel.transferOwnership(account) + return nil +} + func (channel *Channel) regenerateMembersCache() { channel.stateMutex.RLock() result := make([]*Client, len(channel.members)) @@ -1117,21 +1206,23 @@ func (channel *Channel) Quit(client *Client) { client.removeChannel(channel) } -func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer) { - if !(client.HasMode(modes.Operator) || channel.hasClient(client)) { - rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.Name(), client.t("You're not on that channel")) - return +func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) { + if !hasPrivs { + if !(client.HasMode(modes.Operator) || channel.hasClient(client)) { + rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.Name(), client.t("You're not on that channel")) + return + } + if !channel.ClientHasPrivsOver(client, target) { + rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You don't have enough channel privileges")) + return + } } if !channel.hasClient(target) { rb.Add(nil, client.server.name, ERR_USERNOTINCHANNEL, client.Nick(), channel.Name(), client.t("They aren't on that channel")) return } - if !channel.ClientHasPrivsOver(client, target) { - rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You don't have enough channel privileges")) - return - } - kicklimit := client.server.Config().Limits.KickLen + kicklimit := channel.server.Config().Limits.KickLen if len(comment) > kicklimit { comment = comment[:kicklimit] } diff --git a/irc/channelmanager.go b/irc/channelmanager.go index 8ceb390b..8a12ed7c 100644 --- a/irc/channelmanager.go +++ b/irc/channelmanager.go @@ -22,6 +22,7 @@ type ChannelManager struct { sync.RWMutex // tier 2 chans map[string]*channelManagerEntry registeredChannels map[string]bool + purgedChannels map[string]empty server *Server } @@ -37,9 +38,11 @@ func (cm *ChannelManager) Initialize(server *Server) { func (cm *ChannelManager) loadRegisteredChannels() { registeredChannels := cm.server.channelRegistry.AllChannels() + purgedChannels := cm.server.channelRegistry.PurgedChannels() cm.Lock() defer cm.Unlock() cm.registeredChannels = registeredChannels + cm.purgedChannels = purgedChannels } // Get returns an existing channel with name equivalent to `name`, or nil @@ -69,6 +72,10 @@ func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin cm.Lock() defer cm.Unlock() + _, purged := cm.purgedChannels[casefoldedName] + if purged { + return nil + } entry := cm.chans[casefoldedName] if entry == nil { registered := cm.registeredChannels[casefoldedName] @@ -267,3 +274,50 @@ func (cm *ChannelManager) Channels() (result []*Channel) { } return } + +// Purge marks a channel as purged. +func (cm *ChannelManager) Purge(chname string, record ChannelPurgeRecord) (err error) { + chname, err = CasefoldChannel(chname) + if err != nil { + return errInvalidChannelName + } + + cm.Lock() + cm.purgedChannels[chname] = empty{} + cm.Unlock() + + cm.server.channelRegistry.PurgeChannel(chname, record) + return nil +} + +// IsPurged queries whether a channel is purged. +func (cm *ChannelManager) IsPurged(chname string) (result bool) { + chname, err := CasefoldChannel(chname) + if err != nil { + return false + } + + cm.Lock() + _, result = cm.purgedChannels[chname] + cm.Unlock() + return +} + +// Unpurge deletes a channel's purged status. +func (cm *ChannelManager) Unpurge(chname string) (err error) { + chname, err = CasefoldChannel(chname) + if err != nil { + return errNoSuchChannel + } + + cm.Lock() + _, found := cm.purgedChannels[chname] + delete(cm.purgedChannels, chname) + cm.Unlock() + + cm.server.channelRegistry.UnpurgeChannel(chname) + if !found { + return errNoSuchChannel + } + return nil +} diff --git a/irc/channelreg.go b/irc/channelreg.go index 6f93104f..ad54affd 100644 --- a/irc/channelreg.go +++ b/irc/channelreg.go @@ -32,6 +32,8 @@ const ( keyChannelPassword = "channel.key %s" keyChannelModes = "channel.modes %s" keyChannelAccountToUMode = "channel.accounttoumode %s" + + keyChannelPurged = "channel.purged %s" ) var ( @@ -96,6 +98,12 @@ type RegisteredChannel struct { Invites map[string]MaskInfo } +type ChannelPurgeRecord struct { + Oper string + PurgedAt time.Time + Reason string +} + // ChannelRegistry manages registered channels. type ChannelRegistry struct { server *Server @@ -124,6 +132,24 @@ func (reg *ChannelRegistry) AllChannels() (result map[string]bool) { return } +// PurgedChannels returns the set of all channel names that have been purged +func (reg *ChannelRegistry) PurgedChannels() (result map[string]empty) { + result = make(map[string]empty) + + prefix := fmt.Sprintf(keyChannelPurged, "") + reg.server.store.View(func(tx *buntdb.Tx) error { + return tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { + if !strings.HasPrefix(key, prefix) { + return false + } + channel := strings.TrimPrefix(key, prefix) + result[channel] = empty{} + return true + }) + }) + return +} + // StoreChannel obtains a consistent view of a channel, then persists it to the store. func (reg *ChannelRegistry) StoreChannel(info RegisteredChannel, includeFlags uint) (err error) { if !reg.server.ChannelRegistrationEnabled() { @@ -191,11 +217,11 @@ func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info RegisteredC info = RegisteredChannel{ Name: name, - RegisteredAt: time.Unix(regTimeInt, 0), + RegisteredAt: time.Unix(regTimeInt, 0).UTC(), Founder: founder, Topic: topic, TopicSetBy: topicSetBy, - TopicSetTime: time.Unix(topicSetTimeInt, 0), + TopicSetTime: time.Unix(topicSetTimeInt, 0).UTC(), Key: password, Modes: modeSlice, Bans: banlist, @@ -256,14 +282,12 @@ func (reg *ChannelRegistry) deleteChannel(tx *buntdb.Tx, key string, info Regist } } -// saveChannel saves a channel to the store. -func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredChannel, includeFlags uint) { +func (reg *ChannelRegistry) updateAccountToChannelMapping(tx *buntdb.Tx, channelInfo RegisteredChannel) { channelKey := channelInfo.NameCasefolded - // maintain the mapping of account -> registered channels - chanExistsKey := fmt.Sprintf(keyChannelExists, channelKey) - _, existsErr := tx.Get(chanExistsKey) - if existsErr == buntdb.ErrNotFound { - // this is a new registration, need to update account-to-channels + chanFounderKey := fmt.Sprintf(keyChannelFounder, channelKey) + founder, existsErr := tx.Get(chanFounderKey) + if existsErr == buntdb.ErrNotFound || founder != channelInfo.Founder { + // add to new founder's list accountChannelsKey := fmt.Sprintf(keyAccountChannels, channelInfo.Founder) alreadyChannels, _ := tx.Get(accountChannelsKey) newChannels := channelKey // this is the casefolded channel name @@ -272,9 +296,30 @@ func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredCha } tx.Set(accountChannelsKey, newChannels, nil) } + if existsErr == nil && founder != channelInfo.Founder { + // remove from old founder's list + accountChannelsKey := fmt.Sprintf(keyAccountChannels, founder) + alreadyChannelsRaw, _ := tx.Get(accountChannelsKey) + var newChannels []string + if alreadyChannelsRaw != "" { + for _, chname := range strings.Split(alreadyChannelsRaw, ",") { + if chname != channelInfo.NameCasefolded { + newChannels = append(newChannels, chname) + } + } + } + tx.Set(accountChannelsKey, strings.Join(newChannels, ","), nil) + } +} + +// saveChannel saves a channel to the store. +func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredChannel, includeFlags uint) { + channelKey := channelInfo.NameCasefolded + // maintain the mapping of account -> registered channels + reg.updateAccountToChannelMapping(tx, channelInfo) if includeFlags&IncludeInitial != 0 { - tx.Set(chanExistsKey, "1", nil) + tx.Set(fmt.Sprintf(keyChannelExists, channelKey), "1", nil) tx.Set(fmt.Sprintf(keyChannelName, channelKey), channelInfo.Name, nil) tx.Set(fmt.Sprintf(keyChannelRegTime, channelKey), strconv.FormatInt(channelInfo.RegisteredAt.Unix(), 10), nil) tx.Set(fmt.Sprintf(keyChannelFounder, channelKey), channelInfo.Founder, nil) @@ -306,3 +351,48 @@ func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredCha tx.Set(fmt.Sprintf(keyChannelAccountToUMode, channelKey), string(accountToUModeString), nil) } } + +// PurgeChannel records a channel purge. +func (reg *ChannelRegistry) PurgeChannel(chname string, record ChannelPurgeRecord) (err error) { + serialized, err := json.Marshal(record) + if err != nil { + return err + } + serializedStr := string(serialized) + key := fmt.Sprintf(keyChannelPurged, chname) + + return reg.server.store.Update(func(tx *buntdb.Tx) error { + tx.Set(key, serializedStr, nil) + return nil + }) +} + +// LoadPurgeRecord retrieves information about whether and how a channel was purged. +func (reg *ChannelRegistry) LoadPurgeRecord(chname string) (record ChannelPurgeRecord, err error) { + var rawRecord string + key := fmt.Sprintf(keyChannelPurged, chname) + reg.server.store.View(func(tx *buntdb.Tx) error { + rawRecord, _ = tx.Get(key) + return nil + }) + if rawRecord == "" { + err = errNoSuchChannel + return + } + err = json.Unmarshal([]byte(rawRecord), &record) + if err != nil { + reg.server.logger.Error("internal", "corrupt purge record", chname, err.Error()) + err = errNoSuchChannel + return + } + return +} + +// UnpurgeChannel deletes the record of a channel purge. +func (reg *ChannelRegistry) UnpurgeChannel(chname string) (err error) { + key := fmt.Sprintf(keyChannelPurged, chname) + return reg.server.store.Update(func(tx *buntdb.Tx) error { + tx.Delete(key) + return nil + }) +} diff --git a/irc/chanserv.go b/irc/chanserv.go index 0d6b02ae..f0be2dd5 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -18,6 +18,7 @@ import ( ) const chanservHelp = `ChanServ lets you register and manage channels.` +const chanservMask = "ChanServ!ChanServ@localhost" func chanregEnabled(config *Config) bool { return config.Channels.Registration.Enabled @@ -75,12 +76,71 @@ referenced by their registered account names, not their nicknames.`, enabled: chanregEnabled, minParams: 1, }, + "clear": { + handler: csClearHandler, + help: `Syntax: $bCLEAR #channel target$b + +CLEAR removes users or settings from a channel. Specifically: + +$bCLEAR #channel users$b kicks all users except for you. +$bCLEAR #channel access$b resets all stored bans, invites, ban exceptions, +and persistent user-mode grants made with CS AMODE.`, + helpShort: `$bCLEAR$b removes users or settings from a channel.`, + enabled: chanregEnabled, + minParams: 2, + }, + "transfer": { + handler: csTransferHandler, + help: `Syntax: $bTRANSFER [accept] #channel user [code]$b + +TRANSFER transfers ownership of a channel from one user to another. +To prevent accidental transfers, a verification code is required. For +example, $bTRANSFER #channel alice$b displays the required confirmation +code, then $bTRANSFER #channel alice 2930242125$b initiates the transfer. +Unless you are an IRC operator with the correct permissions, alice must +then accept the transfer, which she can do with $bTRANSFER accept #channel$b.`, + helpShort: `$bTRANSFER$b transfers ownership of a channel to another user.`, + enabled: chanregEnabled, + minParams: 2, + }, + "purge": { + handler: csPurgeHandler, + help: `Syntax: $bPURGE #channel [reason]$b + +PURGE blacklists a channel from the server, making it impossible to join +or otherwise interact with the channel. If the channel currently has members, +they will be kicked from it. PURGE may also be applied preemptively to +channels that do not currently have members.`, + helpShort: `$bPURGE$b blacklists a channel from the server.`, + capabs: []string{"chanreg"}, + minParams: 1, + maxParams: 2, + unsplitFinalParam: true, + }, + "unpurge": { + handler: csUnpurgeHandler, + help: `Syntax: $bUNPURGE #channel$b + +UNPURGE removes any blacklisting of a channel that was previously +set using PURGE.`, + helpShort: `$bUNPURGE$b undoes a previous PURGE command.`, + capabs: []string{"chanreg"}, + minParams: 1, + }, + "info": { + handler: csInfoHandler, + help: `Syntax: $INFO #channel$b + +INFO displays info about a registered channel.`, + helpShort: `$bINFO$b displays info about a registered channel.`, + minParams: 1, + }, } ) // csNotice sends the client a notice from ChanServ func csNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "ChanServ!ChanServ@localhost", "NOTICE", rb.target.Nick(), text) + rb.Add(nil, chanservMask, "NOTICE", rb.target.Nick(), text) } func csAmodeHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { @@ -219,9 +279,7 @@ func csRegisterHandler(server *Server, client *Client, command string, params [] } account := client.Account() - channelsAlreadyRegistered := server.accounts.ChannelsForAccount(account) - if server.Config().Channels.Registration.MaxChannelsPerAccount <= len(channelsAlreadyRegistered) { - csNotice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER")) + if !checkChanLimit(client, rb) { return } @@ -234,7 +292,7 @@ func csRegisterHandler(server *Server, client *Client, command string, params [] csNotice(rb, fmt.Sprintf(client.t("Channel %s successfully registered"), channelName)) - server.logger.Info("services", fmt.Sprintf("Client %s registered channel %s", client.nick, channelName)) + server.logger.Info("services", fmt.Sprintf("Client %s registered channel %s", client.Nick(), channelName)) server.snomasks.Send(sno.LocalChannels, fmt.Sprintf(ircfmt.Unescape("Channel registered $c[grey][$r%s$c[grey]] by $c[grey][$r%s$c[grey]]"), channelName, client.nickMaskString)) // give them founder privs @@ -249,6 +307,17 @@ func csRegisterHandler(server *Server, client *Client, command string, params [] } } +// check whether a client has already registered too many channels +func checkChanLimit(client *Client, rb *ResponseBuffer) (ok bool) { + account := client.Account() + channelsAlreadyRegistered := client.server.accounts.ChannelsForAccount(account) + ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg") + if !ok { + csNotice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER")) + } + return +} + func csUnregisterHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { channelName := params[0] var verificationCode string @@ -299,3 +368,204 @@ func unregisterConfirmationCode(name string, registeredAt time.Time) (code strin codeInput.WriteString(strconv.FormatInt(registeredAt.Unix(), 16)) return strconv.Itoa(int(crc32.ChecksumIEEE(codeInput.Bytes()))) } + +func csClearHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + channel := server.channels.Get(params[0]) + if channel == nil { + csNotice(rb, client.t("Channel does not exist")) + return + } + account := client.Account() + if !(client.HasRoleCapabs("chanreg") || (account != "" && account == channel.Founder())) { + csNotice(rb, client.t("Insufficient privileges")) + return + } + + switch strings.ToLower(params[1]) { + case "access": + channel.resetAccess() + csNotice(rb, client.t("Successfully reset channel access")) + case "users": + for _, target := range channel.Members() { + if target != client { + channel.Kick(client, target, "Cleared by ChanServ", rb, true) + } + } + default: + csNotice(rb, client.t("Invalid parameters")) + } + +} + +func csTransferHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + if strings.ToLower(params[0]) == "accept" { + processTransferAccept(client, params[1], rb) + return + } + chname := params[0] + channel := server.channels.Get(chname) + if channel == nil { + csNotice(rb, client.t("Channel does not exist")) + return + } + regInfo := channel.ExportRegistration(0) + chname = regInfo.Name + account := client.Account() + isFounder := account != "" && account == regInfo.Founder + hasPrivs := client.HasRoleCapabs("chanreg") + if !(isFounder || hasPrivs) { + csNotice(rb, client.t("Insufficient privileges")) + return + } + target := params[1] + _, err := server.accounts.LoadAccount(params[1]) + if err != nil { + csNotice(rb, client.t("Account does not exist")) + return + } + expectedCode := unregisterConfirmationCode(regInfo.Name, regInfo.RegisteredAt) + codeValidated := 2 < len(params) && params[2] == expectedCode + if !codeValidated { + csNotice(rb, ircfmt.Unescape(client.t("$bWarning: you are about to transfer control of your channel to another user.$b"))) + csNotice(rb, fmt.Sprintf(client.t("To confirm your channel transfer, type: /CS TRANSFER %[1]s %[2]s %[3]s"), chname, target, expectedCode)) + return + } + status, err := channel.Transfer(client, target, hasPrivs) + if err == nil { + switch status { + case channelTransferComplete: + csNotice(rb, fmt.Sprintf(client.t("Successfully transferred channel %[1]s to account %[2]s"), chname, target)) + case channelTransferPending: + sendTransferPendingNotice(server, target, chname) + csNotice(rb, fmt.Sprintf(client.t("Transfer of channel %[1]s to account %[2]s succeeded, pending acceptance"), chname, target)) + case channelTransferCancelled: + csNotice(rb, fmt.Sprintf(client.t("Cancelled pending transfer of channel %s"), chname)) + } + } else { + csNotice(rb, client.t("Could not transfer channel")) + } +} + +func sendTransferPendingNotice(server *Server, account, chname string) { + clients := server.accounts.AccountToClients(account) + if len(clients) == 0 { + return + } + var client *Client + for _, candidate := range clients { + client = candidate + if candidate.NickCasefolded() == candidate.Account() { + break // prefer the login where the nick is the account + } + } + client.Send(nil, chanservMask, "NOTICE", client.Nick(), fmt.Sprintf(client.t("You have been offered ownership of channel %s. To accept, /CS TRANSFER ACCEPT %s"), chname, chname)) +} + +func processTransferAccept(client *Client, chname string, rb *ResponseBuffer) { + channel := client.server.channels.Get(chname) + if channel == nil { + csNotice(rb, client.t("Channel does not exist")) + return + } + if !checkChanLimit(client, rb) { + return + } + switch channel.AcceptTransfer(client) { + case nil: + csNotice(rb, fmt.Sprintf(client.t("Successfully accepted ownership of channel %s"), channel.Name())) + case errChannelTransferNotOffered: + csNotice(rb, fmt.Sprintf(client.t("You weren't offered ownership of channel %s"), channel.Name())) + default: + csNotice(rb, fmt.Sprintf(client.t("Could not accept ownership of channel %s"), channel.Name())) + } +} + +func csPurgeHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + oper := client.Oper() + if oper == nil { + return // should be impossible because you need oper capabs for this + } + + chname := params[0] + var reason string + if 1 < len(params) { + reason = params[1] + } + purgeRecord := ChannelPurgeRecord{ + Oper: oper.Name, + PurgedAt: time.Now().UTC(), + Reason: reason, + } + switch server.channels.Purge(chname, purgeRecord) { + case nil: + channel := server.channels.Get(chname) + if channel != nil { // channel need not exist to be purged + for _, target := range channel.Members() { + channel.Kick(client, target, "Cleared by ChanServ", rb, true) + } + } + csNotice(rb, fmt.Sprintf(client.t("Successfully purged channel %s from the server"), chname)) + case errInvalidChannelName: + csNotice(rb, fmt.Sprintf(client.t("Can't purge invalid channel %s"), chname)) + default: + csNotice(rb, client.t("An error occurred")) + } +} + +func csUnpurgeHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + chname := params[0] + switch server.channels.Unpurge(chname) { + case nil: + csNotice(rb, fmt.Sprintf(client.t("Successfully unpurged channel %s from the server"), chname)) + case errNoSuchChannel: + csNotice(rb, fmt.Sprintf(client.t("Channel %s wasn't previously purged from the server"), chname)) + default: + csNotice(rb, client.t("An error occurred")) + } +} + +func csInfoHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + chname, err := CasefoldChannel(params[0]) + if err != nil { + csNotice(rb, client.t("Invalid channel name")) + return + } + + // purge status + if client.HasRoleCapabs("chanreg") { + purgeRecord, err := server.channelRegistry.LoadPurgeRecord(chname) + if err == nil { + csNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname)) + csNotice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper)) + csNotice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123))) + if purgeRecord.Reason != "" { + csNotice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason)) + } + } + } else { + if server.channels.IsPurged(chname) { + csNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname)) + } + } + + var chinfo RegisteredChannel + channel := server.channels.Get(params[0]) + if channel != nil { + chinfo = channel.ExportRegistration(0) + } else { + chinfo, err = server.channelRegistry.LoadChannel(chname) + if err != nil && !(err == errNoSuchChannel || err == errFeatureDisabled) { + csNotice(rb, client.t("An error occurred")) + return + } + } + + // channel exists but is unregistered, or doesn't exist: + if chinfo.Founder == "" { + csNotice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname)) + return + } + csNotice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name)) + csNotice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder)) + csNotice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123))) +} diff --git a/irc/client.go b/irc/client.go index b67e78b1..9cb006b2 100644 --- a/irc/client.go +++ b/irc/client.go @@ -637,11 +637,11 @@ func (session *Session) playResume() { } } else { if !session.resumeDetails.HistoryIncomplete { - fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected"))) + fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected")) } else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() { fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of message history lost)"), gapSeconds)) } else { - fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (message history may have been lost)"))) + fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected (message history may have been lost)")) } } } diff --git a/irc/errors.go b/irc/errors.go index 5874f690..f3dafd0f 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -30,6 +30,7 @@ var ( errCallbackFailed = errors.New("Account verification could not be sent") errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`) errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account") + errChannelTransferNotOffered = errors.New(`You weren't offered ownership of that channel`) errChannelAlreadyRegistered = errors.New("Channel is already registered") errChannelNameInUse = errors.New(`Channel name in use`) errInvalidChannelName = errors.New(`Invalid channel name`) diff --git a/irc/handlers.go b/irc/handlers.go index dbcc5cb7..8bab0a04 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1370,7 +1370,7 @@ func kickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp if comment == "" { comment = kick.nick } - channel.Kick(client, target, comment, rb) + channel.Kick(client, target, comment, rb, false) } return false } @@ -1725,15 +1725,14 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res prefix := client.NickMaskString() //TODO(dan): we should change the name of String and make it return a slice here args := append([]string{channel.name}, strings.Split(applied.String(), " ")...) + rb.Add(nil, prefix, "MODE", args...) + for _, session := range client.Sessions() { + if session != rb.session { + session.Send(nil, prefix, "MODE", args...) + } + } for _, member := range channel.Members() { - if member == client { - rb.Add(nil, prefix, "MODE", args...) - for _, session := range client.Sessions() { - if session != rb.session { - session.Send(nil, prefix, "MODE", args...) - } - } - } else { + if member != client { member.Send(nil, prefix, "MODE", args...) } } @@ -2359,7 +2358,7 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re if reason != "" { targetRb.Add(nil, targetPrefix, "PART", oldName, fmt.Sprintf(mcl.t("Channel renamed: %s"), reason)) } else { - targetRb.Add(nil, targetPrefix, "PART", oldName, fmt.Sprintf(mcl.t("Channel renamed"))) + targetRb.Add(nil, targetPrefix, "PART", oldName, mcl.t("Channel renamed")) } if mSession.capabilities.Has(caps.ExtendedJoin) { targetRb.Add(nil, targetPrefix, "JOIN", newName, mDetails.accountName, mDetails.realname) diff --git a/irc/hostserv.go b/irc/hostserv.go index d7eb243b..8b1242e8 100644 --- a/irc/hostserv.go +++ b/irc/hostserv.go @@ -186,7 +186,7 @@ func hsRequestHandler(server *Server, client *Client, command string, params []s if err != nil { hsNotice(rb, client.t("An error occurred")) } else { - hsNotice(rb, fmt.Sprintf(client.t("Your vhost request will be reviewed by an administrator"))) + hsNotice(rb, client.t("Your vhost request will be reviewed by an administrator")) chanMsg := fmt.Sprintf("Account %s requests vhost %s", accountName, vhost) hsNotifyChannel(server, chanMsg) // TODO send admins a snomask of some kind @@ -221,7 +221,7 @@ func hsStatusHandler(server *Server, client *Client, command string, params []st if account.VHost.ApprovedVHost != "" { hsNotice(rb, fmt.Sprintf(client.t("Account %[1]s has vhost: %[2]s"), accountName, account.VHost.ApprovedVHost)) if !account.VHost.Enabled { - hsNotice(rb, fmt.Sprintf(client.t("This vhost is currently disabled, but can be enabled with /HS ON"))) + hsNotice(rb, client.t("This vhost is currently disabled, but can be enabled with /HS ON")) } } else { hsNotice(rb, fmt.Sprintf(client.t("Account %s has no vhost"), accountName)) diff --git a/irc/modes.go b/irc/modes.go index 66c17859..4b14869b 100644 --- a/irc/modes.go +++ b/irc/modes.go @@ -119,6 +119,9 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c if isSamode { return true } + if details.account != "" && details.account == channel.Founder() { + return true + } switch change.Mode { case modes.ChannelFounder, modes.ChannelAdmin, modes.ChannelOperator, modes.Halfop, modes.Voice: // List on these modes is a no-op anyway @@ -289,15 +292,16 @@ func (channel *Channel) ProcessAccountToUmodeChange(client *Client, change modes targetModeAfter = change.Mode } - // operators and founders can do anything + // server operators and founders can do anything: hasPrivs := isOperChange || (account != "" && account == channel.registeredFounder) - // halfop and up can list, and do add/removes at levels <= their own + // halfop and up can list: if change.Op == modes.List && (clientMode == modes.Halfop || umodeGreaterThan(clientMode, modes.Halfop)) { hasPrivs = true + // you can do adds or removes at levels you have "privileges over": } else if channelUserModeHasPrivsOver(clientMode, targetModeNow) && channelUserModeHasPrivsOver(clientMode, targetModeAfter) { hasPrivs = true + // and you can always de-op yourself: } else if change.Op == modes.Remove && account == change.Arg { - // you can always de-op yourself hasPrivs = true } if !hasPrivs { diff --git a/irc/nickserv.go b/irc/nickserv.go index f0bd0037..ac8ddfdb 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -310,19 +310,19 @@ func displaySetting(settingName string, settings AccountSettings, client *Client } case "bouncer": if !config.Accounts.Bouncer.Enabled { - nsNotice(rb, fmt.Sprintf(client.t("This feature has been disabled by the server administrators"))) + nsNotice(rb, client.t("This feature has been disabled by the server administrators")) } else { switch settings.AllowBouncer { case BouncerAllowedServerDefault: if config.Accounts.Bouncer.AllowedByDefault { - nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently enabled for your account, but you can opt out"))) + nsNotice(rb, client.t("Bouncer functionality is currently enabled for your account, but you can opt out")) } else { - nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently disabled for your account, but you can opt in"))) + nsNotice(rb, client.t("Bouncer functionality is currently disabled for your account, but you can opt in")) } case BouncerDisallowedByUser: - nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently disabled for your account"))) + nsNotice(rb, client.t("Bouncer functionality is currently disabled for your account")) case BouncerAllowedByUser: - nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently enabled for your account"))) + nsNotice(rb, client.t("Bouncer functionality is currently enabled for your account")) } } default: @@ -569,7 +569,7 @@ func nsInfoHandler(server *Server, client *Client, command string, params []stri } nsNotice(rb, fmt.Sprintf(client.t("Account: %s"), account.Name)) - registeredAt := account.RegisteredAt.Format("Jan 02, 2006 15:04:05Z") + registeredAt := account.RegisteredAt.Format(time.RFC1123) nsNotice(rb, fmt.Sprintf(client.t("Registered at: %s"), registeredAt)) // TODO nicer formatting for this for _, nick := range account.AdditionalNicks {