From 8fc588375b0202f55f63c3f6623612ddf0a2f73e Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 19 May 2019 04:27:44 -0400 Subject: [PATCH] implement user preferences system --- irc/accounts.go | 213 ++++++++++++++++++----------- irc/caps/set.go | 6 - irc/channel.go | 23 +++- irc/chanserv.go | 2 +- irc/client.go | 1 + irc/client_lookup_set.go | 17 ++- irc/config.go | 54 ++++---- irc/database.go | 38 +++++- irc/errors.go | 4 +- irc/getters.go | 13 ++ irc/handlers.go | 4 +- irc/hostserv.go | 2 +- irc/idletimer.go | 6 +- irc/nickserv.go | 285 +++++++++++++++++++++++++++++++++++---- irc/services.go | 12 +- oragono.yaml | 2 +- 16 files changed, 515 insertions(+), 167 deletions(-) diff --git a/irc/accounts.go b/irc/accounts.go index c617f14b..81dfecb4 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -29,7 +29,7 @@ const ( keyAccountRegTime = "account.registered.time %s" keyAccountCredentials = "account.credentials %s" keyAccountAdditionalNicks = "account.additionalnicks %s" - keyAccountEnforcement = "account.customenforcement %s" + keyAccountSettings = "account.settings %s" keyAccountVHost = "account.vhost %s" keyCertToAccount = "account.creds.certfp %s" keyAccountChannels = "account.channels %s" @@ -55,14 +55,14 @@ type AccountManager struct { accountToClients map[string][]*Client nickToAccount map[string]string skeletonToAccount map[string]string - accountToMethod map[string]NickReservationMethod + accountToMethod map[string]NickEnforcementMethod } func (am *AccountManager) Initialize(server *Server) { am.accountToClients = make(map[string][]*Client) am.nickToAccount = make(map[string]string) am.skeletonToAccount = make(map[string]string) - am.accountToMethod = make(map[string]NickReservationMethod) + am.accountToMethod = make(map[string]NickEnforcementMethod) am.server = server am.buildNickToAccountIndex() @@ -76,7 +76,7 @@ func (am *AccountManager) buildNickToAccountIndex() { nickToAccount := make(map[string]string) skeletonToAccount := make(map[string]string) - accountToMethod := make(map[string]NickReservationMethod) + accountToMethod := make(map[string]NickEnforcementMethod) existsPrefix := fmt.Sprintf(keyAccountExists, "") am.serialCacheUpdateMutex.Lock() @@ -109,12 +109,16 @@ func (am *AccountManager) buildNickToAccountIndex() { } } - if methodStr, err := tx.Get(fmt.Sprintf(keyAccountEnforcement, account)); err == nil { - method, err := nickReservationFromString(methodStr) - if err == nil { - accountToMethod[account] = method + if rawPrefs, err := tx.Get(fmt.Sprintf(keyAccountSettings, account)); err == nil { + var prefs AccountSettings + err := json.Unmarshal([]byte(rawPrefs), &prefs) + if err == nil && prefs.NickEnforcement != NickEnforcementOptional { + accountToMethod[account] = prefs.NickEnforcement + } else { + am.server.logger.Error("internal", "corrupt account creds", account) } } + return true }) return err @@ -180,36 +184,44 @@ func (am *AccountManager) NickToAccount(nick string) string { return am.nickToAccount[cfnick] } +// given an account, combine stored enforcement method with the config settings +// to compute the actual enforcement method +func configuredEnforcementMethod(config *Config, storedMethod NickEnforcementMethod) (result NickEnforcementMethod) { + if !config.Accounts.NickReservation.Enabled { + return NickEnforcementNone + } + result = storedMethod + // if they don't have a custom setting, or customization is disabled, use the default + if result == NickEnforcementOptional || !config.Accounts.NickReservation.AllowCustomEnforcement { + result = config.Accounts.NickReservation.Method + } + if result == NickEnforcementOptional { + // enforcement was explicitly enabled neither in the config or by the user + result = NickEnforcementNone + } + return +} + // Given a nick, looks up the account that owns it and the method (none/timeout/strict) // used to enforce ownership. -func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickReservationMethod) { +func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickEnforcementMethod) { config := am.server.Config() if !config.Accounts.NickReservation.Enabled { - return "", NickReservationNone + return "", NickEnforcementNone } am.RLock() defer am.RUnlock() - // given an account, combine stored enforcement method with the config settings - // to compute the actual enforcement method - finalEnforcementMethod := func(account_ string) (result NickReservationMethod) { - result = am.accountToMethod[account_] - // if they don't have a custom setting, or customization is disabled, use the default - if result == NickReservationOptional || !config.Accounts.NickReservation.AllowCustomEnforcement { - result = config.Accounts.NickReservation.Method - } - if result == NickReservationOptional { - // enforcement was explicitly enabled neither in the config or by the user - result = NickReservationNone - } - return + finalEnforcementMethod := func(account_ string) (result NickEnforcementMethod) { + storedMethod := am.accountToMethod[account_] + return configuredEnforcementMethod(config, storedMethod) } nickAccount := am.nickToAccount[cfnick] skelAccount := am.skeletonToAccount[skeleton] if nickAccount == "" && skelAccount == "" { - return "", NickReservationNone + return "", NickEnforcementNone } else if nickAccount != "" && (skelAccount == nickAccount || skelAccount == "") { return nickAccount, finalEnforcementMethod(nickAccount) } else if skelAccount != "" && nickAccount == "" { @@ -220,75 +232,47 @@ func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account st nickMethod := finalEnforcementMethod(nickAccount) skelMethod := finalEnforcementMethod(skelAccount) switch { - case skelMethod == NickReservationNone: + case skelMethod == NickEnforcementNone: return nickAccount, nickMethod - case nickMethod == NickReservationNone: + case nickMethod == NickEnforcementNone: return skelAccount, skelMethod default: // nobody can use this nick - return "!", NickReservationStrict + return "!", NickEnforcementStrict } } } -func (am *AccountManager) BouncerAllowed(account string, session *Session) bool { - // TODO stub - config := am.server.Config() - if !config.Accounts.Bouncer.Enabled { - return false - } - if config.Accounts.Bouncer.AllowedByDefault { - return true - } - return session != nil && session.capabilities.Has(caps.Bouncer) -} - -// Looks up the enforcement method stored in the database for an account -// (typically you want EnforcementStatus instead, which respects the config) -func (am *AccountManager) getStoredEnforcementStatus(account string) string { - am.RLock() - defer am.RUnlock() - return nickReservationToString(am.accountToMethod[account]) -} - // Sets a custom enforcement method for an account and stores it in the database. -func (am *AccountManager) SetEnforcementStatus(account string, method NickReservationMethod) (err error) { +func (am *AccountManager) SetEnforcementStatus(account string, method NickEnforcementMethod) (finalSettings AccountSettings, err error) { config := am.server.Config() if !(config.Accounts.NickReservation.Enabled && config.Accounts.NickReservation.AllowCustomEnforcement) { - return errFeatureDisabled + err = errFeatureDisabled + return } - var serialized string - if method == NickReservationOptional { - serialized = "" // normally this is "default", but we're going to delete the key - } else { - serialized = nickReservationToString(method) + setter := func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.NickEnforcement = method + return out, nil } - key := fmt.Sprintf(keyAccountEnforcement, account) + _, err = am.ModifyAccountSettings(account, setter) + if err != nil { + return + } + // this update of the data plane is racey, but it's probably fine am.Lock() defer am.Unlock() - currentMethod := am.accountToMethod[account] - if method != currentMethod { - if method == NickReservationOptional { - delete(am.accountToMethod, account) - } else { - am.accountToMethod[account] = method - } - - return am.server.store.Update(func(tx *buntdb.Tx) (err error) { - if serialized != "" { - _, _, err = tx.Set(key, nickReservationToString(method), nil) - } else { - _, err = tx.Delete(key) - } - return - }) + if method == NickEnforcementOptional { + delete(am.accountToMethod, account) + } else { + am.accountToMethod[account] = method } - return nil + return } func (am *AccountManager) AccountToClients(account string) (result []*Client) { @@ -813,6 +797,12 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount) (result Cl // pretend they have no vhost and move on } } + if raw.Settings != "" { + e := json.Unmarshal([]byte(raw.Settings), &result.Settings) + if e != nil { + am.server.logger.Warning("internal", "could not unmarshal settings for account", result.Name, e.Error()) + } + } return } @@ -825,6 +815,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) + settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) _, e := tx.Get(accountKey) if e == buntdb.ErrNotFound { @@ -838,6 +829,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string result.Callback, _ = tx.Get(callbackKey) result.AdditionalNicks, _ = tx.Get(nicksKey) result.VHost, _ = tx.Get(vhostKey) + result.Settings, _ = tx.Get(settingsKey) if _, e = tx.Get(verifiedKey); e == nil { result.Verified = true @@ -861,7 +853,7 @@ func (am *AccountManager) Unregister(account string) error { verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) - enforcementKey := fmt.Sprintf(keyAccountEnforcement, casefoldedAccount) + settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) vhostQueueKey := fmt.Sprintf(keyVHostQueueAcctToId, casefoldedAccount) channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount) @@ -892,7 +884,7 @@ func (am *AccountManager) Unregister(account string) error { tx.Delete(registeredTimeKey) tx.Delete(callbackKey) tx.Delete(verificationCodeKey) - tx.Delete(enforcementKey) + tx.Delete(settingsKey) rawNicks, _ = tx.Get(nicksKey) tx.Delete(nicksKey) credText, err = tx.Get(credentialsKey) @@ -980,19 +972,13 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client) error { } var account string - var rawAccount rawClientAccount certFPKey := fmt.Sprintf(keyCertToAccount, client.certfp) - err := am.server.store.Update(func(tx *buntdb.Tx) error { - var err error + err := am.server.store.View(func(tx *buntdb.Tx) error { account, _ = tx.Get(certFPKey) if account == "" { return errAccountInvalidCredentials } - rawAccount, err = am.loadRawAccount(tx, account) - if err != nil || !rawAccount.Verified { - return errAccountUnverified - } return nil }) @@ -1001,14 +987,57 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client) error { } // ok, we found an account corresponding to their certificate - clientAccount, err := am.deserializeRawAccount(rawAccount) + clientAccount, err := am.LoadAccount(account) if err != nil { return err + } else if !clientAccount.Verified { + return errAccountUnverified } am.Login(client, clientAccount) return nil } +type settingsMunger func(input AccountSettings) (output AccountSettings, err error) + +func (am *AccountManager) ModifyAccountSettings(account string, munger settingsMunger) (newSettings AccountSettings, err error) { + casefoldedAccount, err := CasefoldName(account) + if err != nil { + return newSettings, errAccountDoesNotExist + } + // TODO implement this in general via a compare-and-swap API + accountData, err := am.LoadAccount(casefoldedAccount) + if err != nil { + return + } else if !accountData.Verified { + return newSettings, errAccountUnverified + } + newSettings, err = munger(accountData.Settings) + if err != nil { + return + } + text, err := json.Marshal(newSettings) + if err != nil { + return + } + key := fmt.Sprintf(keyAccountSettings, casefoldedAccount) + serializedValue := string(text) + err = am.server.store.Update(func(tx *buntdb.Tx) (err error) { + _, _, err = tx.Set(key, serializedValue, nil) + return + }) + if err != nil { + err = errAccountUpdateFailed + return + } + // success, push new settings into the client objects + am.Lock() + defer am.Unlock() + for _, client := range am.accountToClients[casefoldedAccount] { + client.SetAccountSettings(newSettings) + } + return +} + // represents someone's status in hostserv type VHostInfo struct { ApprovedVHost string @@ -1237,6 +1266,9 @@ func (am *AccountManager) Login(client *Client, account ClientAccount) { am.Lock() defer am.Unlock() am.accountToClients[casefoldedAccount] = append(am.accountToClients[casefoldedAccount], client) + for _, client := range am.accountToClients[casefoldedAccount] { + client.SetAccountSettings(account.Settings) + } } func (am *AccountManager) Logout(client *Client) { @@ -1283,6 +1315,21 @@ type AccountCredentials struct { Certificate string // fingerprint } +type BouncerAllowedSetting int + +const ( + BouncerAllowedServerDefault BouncerAllowedSetting = iota + BouncerDisallowedByUser + BouncerAllowedByUser +) + +type AccountSettings struct { + AutoreplayLines *int + NickEnforcement NickEnforcementMethod + AllowBouncer BouncerAllowedSetting + AutoreplayJoins bool +} + // ClientAccount represents a user account. type ClientAccount struct { // Name of the account. @@ -1293,6 +1340,7 @@ type ClientAccount struct { Verified bool AdditionalNicks []string VHost VHostInfo + Settings AccountSettings } // convenience for passing around raw serialized account data @@ -1304,6 +1352,7 @@ type rawClientAccount struct { Verified bool AdditionalNicks string VHost string + Settings string } // logoutOfAccount logs the client out of their current account. diff --git a/irc/caps/set.go b/irc/caps/set.go index 12b6fd16..0e5f7949 100644 --- a/irc/caps/set.go +++ b/irc/caps/set.go @@ -114,9 +114,3 @@ func (s *Set) String(version Version, values *Values) string { return strings.Join(strs, " ") } - -// returns whether we should send `znc.in/self-message`-style echo messages -// to sessions other than that which originated the message -func (capabs *Set) SelfMessagesEnabled() bool { - return capabs.Has(EchoMessage) || capabs.Has(ZNCSelfMessage) -} diff --git a/irc/channel.go b/irc/channel.go index 90306380..48e51cef 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -620,7 +620,17 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp // TODO #259 can be implemented as Flush(false) (i.e., nonblocking) while holding joinPartMutex rb.Flush(true) - replayLimit := channel.server.Config().History.AutoreplayOnJoin + var replayLimit int + customReplayLimit := client.AccountSettings().AutoreplayLines + if customReplayLimit != nil { + replayLimit = *customReplayLimit + maxLimit := channel.server.Config().History.ChathistoryMax + if maxLimit < replayLimit { + replayLimit = maxLimit + } + } else { + replayLimit = channel.server.Config().History.AutoreplayOnJoin + } if 0 < replayLimit { // TODO don't replay the client's own JOIN line? items := channel.history.Latest(replayLimit) @@ -782,6 +792,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I client := rb.target eventPlayback := rb.session.capabilities.Has(caps.EventPlayback) extendedJoin := rb.session.capabilities.Has(caps.ExtendedJoin) + playJoinsAsPrivmsg := (!autoreplay || client.AccountSettings().AutoreplayJoins) if len(items) == 0 { return @@ -808,7 +819,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname) } } else { - if autoreplay { + if !playJoinsAsPrivmsg { continue // #474 } var message string @@ -823,7 +834,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I if eventPlayback { rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "PART", chname, item.Message.Message) } else { - if autoreplay { + if !playJoinsAsPrivmsg { continue // #474 } message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message) @@ -840,7 +851,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I if eventPlayback { rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "QUIT", item.Message.Message) } else { - if autoreplay { + if !playJoinsAsPrivmsg { continue // #474 } message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message) @@ -989,7 +1000,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod } // send echo-message to other connected sessions for _, session := range client.Sessions() { - if session == rb.session || !session.capabilities.SelfMessagesEnabled() { + if session == rb.session { continue } var tagsToUse map[string]string @@ -998,7 +1009,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod } if histType == history.Tagmsg && session.capabilities.Has(caps.MessageTags) { session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname) - } else { + } else if histType != history.Tagmsg { session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message) } } diff --git a/irc/chanserv.go b/irc/chanserv.go index b37ba98d..4cfd2bf9 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -86,7 +86,7 @@ referenced by their registered account names, not their nicknames.`, // csNotice sends the client a notice from ChanServ func csNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "ChanServ", "NOTICE", rb.target.Nick(), text) + rb.Add(nil, "ChanServ!ChanServ@localhost", "NOTICE", rb.target.Nick(), text) } func csAmodeHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { diff --git a/irc/client.go b/irc/client.go index 33dfe9a1..3beb80d6 100644 --- a/irc/client.go +++ b/irc/client.go @@ -48,6 +48,7 @@ type ResumeDetails struct { type Client struct { account string accountName string // display name of the account: uncasefolded, '*' if not logged in + accountSettings AccountSettings atime time.Time away bool awayMessage string diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 65ac7127..1108f0d1 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -145,7 +145,20 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick reservedAccount, method := client.server.accounts.EnforcementStatus(newcfnick, newSkeleton) account := client.Account() - bouncerAllowed := client.server.accounts.BouncerAllowed(account, session) + config := client.server.Config() + var bouncerAllowed bool + if config.Accounts.Bouncer.Enabled { + if session != nil && session.capabilities.Has(caps.Bouncer) { + bouncerAllowed = true + } else { + settings := client.AccountSettings() + if config.Accounts.Bouncer.AllowedByDefault && settings.AllowBouncer != BouncerDisallowedByUser { + bouncerAllowed = true + } else if settings.AllowBouncer == BouncerAllowedByUser { + bouncerAllowed = true + } + } + } clients.Lock() defer clients.Unlock() @@ -168,7 +181,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick if skeletonHolder != nil && skeletonHolder != client { return errNicknameInUse } - if method == NickReservationStrict && reservedAccount != "" && reservedAccount != account { + if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account { return errNicknameReserved } clients.removeInternal(client) diff --git a/irc/config.go b/irc/config.go index 8aea256b..965aa9d5 100644 --- a/irc/config.go +++ b/irc/config.go @@ -112,63 +112,63 @@ type VHostConfig struct { } `yaml:"user-requests"` } -type NickReservationMethod int +type NickEnforcementMethod int const ( - // NickReservationOptional is the zero value; it serializes to + // NickEnforcementOptional is the zero value; it serializes to // "optional" in the yaml config, and "default" as an arg to `NS ENFORCE`. // in both cases, it means "defer to the other source of truth", i.e., // in the config, defer to the user's custom setting, and as a custom setting, - // defer to the default in the config. if both are NickReservationOptional then + // defer to the default in the config. if both are NickEnforcementOptional then // there is no enforcement. - NickReservationOptional NickReservationMethod = iota - NickReservationNone - NickReservationWithTimeout - NickReservationStrict + // XXX: these are serialized as numbers in the database, so beware of collisions + // when refactoring (any numbers currently in use must keep their meanings, or + // else be fixed up by a schema change) + NickEnforcementOptional NickEnforcementMethod = iota + NickEnforcementNone + NickEnforcementWithTimeout + NickEnforcementStrict ) -func nickReservationToString(method NickReservationMethod) string { +func nickReservationToString(method NickEnforcementMethod) string { switch method { - case NickReservationOptional: + case NickEnforcementOptional: return "default" - case NickReservationNone: + case NickEnforcementNone: return "none" - case NickReservationWithTimeout: + case NickEnforcementWithTimeout: return "timeout" - case NickReservationStrict: + case NickEnforcementStrict: return "strict" default: return "" } } -func nickReservationFromString(method string) (NickReservationMethod, error) { - switch method { +func nickReservationFromString(method string) (NickEnforcementMethod, error) { + switch strings.ToLower(method) { case "default": - return NickReservationOptional, nil + return NickEnforcementOptional, nil case "optional": - return NickReservationOptional, nil + return NickEnforcementOptional, nil case "none": - return NickReservationNone, nil + return NickEnforcementNone, nil case "timeout": - return NickReservationWithTimeout, nil + return NickEnforcementWithTimeout, nil case "strict": - return NickReservationStrict, nil + return NickEnforcementStrict, nil default: - return NickReservationOptional, fmt.Errorf("invalid nick-reservation.method value: %s", method) + return NickEnforcementOptional, fmt.Errorf("invalid nick-reservation.method value: %s", method) } } -func (nr *NickReservationMethod) UnmarshalYAML(unmarshal func(interface{}) error) error { - var orig, raw string +func (nr *NickEnforcementMethod) UnmarshalYAML(unmarshal func(interface{}) error) error { + var orig string var err error if err = unmarshal(&orig); err != nil { return err } - if raw, err = Casefold(orig); err != nil { - return err - } - method, err := nickReservationFromString(raw) + method, err := nickReservationFromString(orig) if err == nil { *nr = method } @@ -178,7 +178,7 @@ func (nr *NickReservationMethod) UnmarshalYAML(unmarshal func(interface{}) error type NickReservationConfig struct { Enabled bool AdditionalNickLimit int `yaml:"additional-nick-limit"` - Method NickReservationMethod + Method NickEnforcementMethod AllowCustomEnforcement bool `yaml:"allow-custom-enforcement"` RenameTimeout time.Duration `yaml:"rename-timeout"` RenamePrefix string `yaml:"rename-prefix"` diff --git a/irc/database.go b/irc/database.go index c85cebc0..62e478b6 100644 --- a/irc/database.go +++ b/irc/database.go @@ -22,7 +22,7 @@ const ( // 'version' of the database schema keySchemaVersion = "db.version" // latest schema of the db - latestDbSchema = "5" + latestDbSchema = "6" ) type SchemaChanger func(*Config, *buntdb.Tx) error @@ -409,6 +409,37 @@ func schemaChangeV4ToV5(config *Config, tx *buntdb.Tx) error { return nil } +// custom nick enforcement was a separate db key, now it's part of settings +func schemaChangeV5ToV6(config *Config, tx *buntdb.Tx) error { + accountToEnforcement := make(map[string]NickEnforcementMethod) + prefix := "account.customenforcement " + tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { + if !strings.HasPrefix(key, prefix) { + return false + } + account := strings.TrimPrefix(key, prefix) + method, err := nickReservationFromString(value) + if err == nil { + accountToEnforcement[account] = method + } else { + log.Printf("skipping corrupt custom enforcement value for %s\n", account) + } + return true + }) + + for account, method := range accountToEnforcement { + var settings AccountSettings + settings.NickEnforcement = method + text, err := json.Marshal(settings) + if err != nil { + return err + } + tx.Delete(prefix + account) + tx.Set(fmt.Sprintf("account.settings %s", account), string(text), nil) + } + return nil +} + func init() { allChanges := []SchemaChange{ { @@ -431,6 +462,11 @@ func init() { TargetVersion: "5", Changer: schemaChangeV4ToV5, }, + { + InitialVersion: "5", + TargetVersion: "6", + Changer: schemaChangeV5ToV6, + }, } // build the index diff --git a/irc/errors.go b/irc/errors.go index 1fa8e5f7..14e833ab 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -19,10 +19,10 @@ var ( errAccountNickReservationFailed = errors.New("Could not (un)reserve nick") errAccountNotLoggedIn = errors.New("You're not logged into an account") errAccountTooManyNicks = errors.New("Account has too many reserved nicks") - errAccountUnverified = errors.New("Account is not yet verified") + errAccountUnverified = errors.New(`Account is not yet verified`) errAccountVerificationFailed = errors.New("Account verification failed") errAccountVerificationInvalidCode = errors.New("Invalid account verification code") - errAccountUpdateFailed = errors.New("Error while updating your account information") + errAccountUpdateFailed = errors.New(`Error while updating your account information`) errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`) errCallbackFailed = errors.New("Account verification could not be sent") errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`) diff --git a/irc/getters.go b/irc/getters.go index d231d66a..13018bba 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -275,6 +275,19 @@ func (client *Client) SetAccountName(account string) (changed bool) { return } +func (client *Client) AccountSettings() (result AccountSettings) { + client.stateMutex.RLock() + result = client.accountSettings + client.stateMutex.RUnlock() + return +} + +func (client *Client) SetAccountSettings(settings AccountSettings) { + client.stateMutex.Lock() + client.accountSettings = settings + client.stateMutex.Unlock() +} + func (client *Client) Languages() (languages []string) { client.stateMutex.RLock() languages = client.languages diff --git a/irc/handlers.go b/irc/handlers.go index 88abf70e..038d296a 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2046,12 +2046,12 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R } // an echo-message may need to go out to other client sessions: for _, session := range client.Sessions() { - if session == rb.session || !rb.session.capabilities.SelfMessagesEnabled() { + if session == rb.session { continue } if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) { session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick) - } else { + } else if histType != history.Tagmsg { session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg) } } diff --git a/irc/hostserv.go b/irc/hostserv.go index a0d7d49c..cc81fbb6 100644 --- a/irc/hostserv.go +++ b/irc/hostserv.go @@ -131,7 +131,7 @@ for the rejection.`, // hsNotice sends the client a notice from HostServ func hsNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "HostServ", "NOTICE", rb.target.Nick(), text) + rb.Add(nil, "HostServ!HostServ@localhost", "NOTICE", rb.target.Nick(), text) } // hsNotifyChannel notifies the designated channel of new vhost activity diff --git a/irc/idletimer.go b/irc/idletimer.go index fe158e46..8b7bc8e8 100644 --- a/irc/idletimer.go +++ b/irc/idletimer.go @@ -198,7 +198,7 @@ func (nt *NickTimer) Initialize(client *Client) { } config := &client.server.Config().Accounts.NickReservation - enabled := config.Enabled && (config.Method == NickReservationWithTimeout || config.AllowCustomEnforcement) + enabled := config.Enabled && (config.Method == NickEnforcementWithTimeout || config.AllowCustomEnforcement) nt.Lock() defer nt.Unlock() @@ -235,7 +235,7 @@ func (nt *NickTimer) Touch(rb *ResponseBuffer) { cfnick, skeleton := nt.client.uniqueIdentifiers() account := nt.client.Account() accountForNick, method := nt.client.server.accounts.EnforcementStatus(cfnick, skeleton) - enforceTimeout := method == NickReservationWithTimeout + enforceTimeout := method == NickEnforcementWithTimeout var shouldWarn, shouldRename bool @@ -258,7 +258,7 @@ func (nt *NickTimer) Touch(rb *ResponseBuffer) { if enforceTimeout && delinquent && (accountChanged || nt.timer == nil) { nt.timer = time.AfterFunc(nt.timeout, nt.processTimeout) shouldWarn = true - } else if method == NickReservationStrict && delinquent { + } else if method == NickEnforcementStrict && delinquent { shouldRename = true // this can happen if reservation was enabled by rehash } }() diff --git a/irc/nickserv.go b/irc/nickserv.go index 4a3496ee..1b14b0f0 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -5,6 +5,8 @@ package irc import ( "fmt" + "strconv" + "strings" "time" "github.com/goshuirc/irc-go/ircfmt" @@ -25,10 +27,6 @@ func servCmdRequiresNickRes(config *Config) bool { return config.Accounts.AuthenticationEnabled && config.Accounts.NickReservation.Enabled } -func nsEnforceEnabled(config *Config) bool { - return servCmdRequiresNickRes(config) && config.Accounts.NickReservation.AllowCustomEnforcement -} - func servCmdRequiresBouncerEnabled(config *Config) bool { return config.Accounts.Bouncer.Enabled } @@ -61,20 +59,14 @@ DROP de-links the given (or your current) nickname from your user account.`, authRequired: true, }, "enforce": { + hidden: true, handler: nsEnforceHandler, help: `Syntax: $bENFORCE [method]$b -ENFORCE lets you specify a custom enforcement mechanism for your registered -nicknames. Your options are: -1. 'none' [no enforcement, overriding the server default] -2. 'timeout' [anyone using the nick must authenticate before a deadline, - or else they will be renamed] -3. 'strict' [you must already be authenticated to use the nick] -4. 'default' [use the server default] -With no arguments, queries your current enforcement status.`, - helpShort: `$bENFORCE$b lets you change how your nicknames are reserved.`, +ENFORCE is an alias for $bGET enforce$b and $bSET enforce$b. See the help +entry for $bSET$b for more information.`, authRequired: true, - enabled: nsEnforceEnabled, + enabled: servCmdRequiresAccreg, }, "ghost": { handler: nsGhostHandler, @@ -194,12 +186,257 @@ password by supplying their username and then the desired password.`, enabled: servCmdRequiresAuthEnabled, minParams: 2, }, + "get": { + handler: nsGetHandler, + help: `Syntax: $bGET $b + +GET queries the current values of your account settings. For more information +on the settings and their possible values, see HELP SET.`, + helpShort: `$bGET$b queries the current values of your account settings`, + authRequired: true, + enabled: servCmdRequiresAccreg, + minParams: 1, + }, + "saget": { + handler: nsGetHandler, + help: `Syntax: $bSAGET $b + +SAGET queries the values of someone else's account settings. For more +information on the settings and their possible values, see HELP SET.`, + helpShort: `$bSAGET$b queries the current values of another user's account settings`, + enabled: servCmdRequiresAccreg, + minParams: 2, + capabs: []string{"accreg"}, + }, + "set": { + handler: nsSetHandler, + help: `Syntax $bSET $b + +Set modifies your account settings. The following settings ara available: + +$bENFORCE$b +'enforce' lets you specify a custom enforcement mechanism for your registered +nicknames. Your options are: +1. 'none' [no enforcement, overriding the server default] +2. 'timeout' [anyone using the nick must authenticate before a deadline, + or else they will be renamed] +3. 'strict' [you must already be authenticated to use the nick] +4. 'default' [use the server default] + +$bBOUNCER$b +If 'bouncer' is enabled and you are already logged in and using a nick, a +second client of yours that authenticates with SASL and requests the same nick +is allowed to attach to the nick as well (this is comparable to the behavior +of IRC "bouncers" like ZNC). Your options are 'on' (allow this behavior), +'off' (disallow it), and 'default' (use the server default value). + +$bAUTOREPLAY-LINES$b +'autoreplay-lines' controls the number of lines of channel history that will +be replayed to you automatically when joining a channel. Your options are any +positive number, 0 to disable the feature, and 'default' to use the server +default. + +$bAUTOREPLAY-JOINS$b +'autoreplay-joins' controls whether autoreplayed channel history will include +lines for join and part. This provides more information about the context of +messages, but may be spammy. Your options are 'on' and 'off'. +`, + helpShort: `$bSET$b modifies your account settings`, + authRequired: true, + enabled: servCmdRequiresAccreg, + minParams: 2, + }, + "saset": { + handler: nsSetHandler, + help: `Syntax: $bSASET $b`, + helpShort: `$bSASET$b modifies another user's account settings`, + enabled: servCmdRequiresAccreg, + minParams: 3, + capabs: []string{"accreg"}, + }, } ) // nsNotice sends the client a notice from NickServ func nsNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "NickServ", "NOTICE", rb.target.Nick(), text) + // XXX i can't figure out how to use OragonoServices[servicename].prefix here + // without creating a compile-time initialization loop + rb.Add(nil, "NickServ!NickServ@localhost", "NOTICE", rb.target.Nick(), text) +} + +func nsGetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + var account string + if command == "saget" { + account = params[0] + params = params[1:] + } else { + account = client.Account() + } + + accountData, err := server.accounts.LoadAccount(account) + if err == errAccountDoesNotExist { + nsNotice(rb, client.t("No such account")) + return + } else if err != nil { + nsNotice(rb, client.t("Error loading account data")) + return + } + + displaySetting(params[0], accountData.Settings, client, rb) +} + +func displaySetting(settingName string, settings AccountSettings, client *Client, rb *ResponseBuffer) { + config := client.server.Config() + switch strings.ToLower(settingName) { + case "enforce": + storedValue := settings.NickEnforcement + serializedStoredValue := nickReservationToString(storedValue) + nsNotice(rb, fmt.Sprintf(client.t("Your stored nickname enforcement setting is: %s"), serializedStoredValue)) + serializedActualValue := nickReservationToString(configuredEnforcementMethod(config, storedValue)) + nsNotice(rb, fmt.Sprintf(client.t("Given current server settings, your nickname is enforced with: %s"), serializedActualValue)) + case "autoreplay-lines": + if settings.AutoreplayLines == nil { + nsNotice(rb, fmt.Sprintf(client.t("You will receive the server default of %d lines of autoreplayed history"), config.History.AutoreplayOnJoin)) + } else { + nsNotice(rb, fmt.Sprintf(client.t("You will receive %d lines of autoreplayed history"), *settings.AutoreplayLines)) + } + case "autoreplay-joins": + if settings.AutoreplayJoins { + nsNotice(rb, client.t("You will see JOINs and PARTs in autoreplayed history lines")) + } else { + nsNotice(rb, client.t("You will not see JOINs and PARTs in autoreplayed history lines")) + } + case "bouncer": + if !config.Accounts.Bouncer.Enabled { + nsNotice(rb, fmt.Sprintf(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"))) + } else { + nsNotice(rb, fmt.Sprintf(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"))) + case BouncerAllowedByUser: + nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently enabled for your account"))) + } + } + default: + nsNotice(rb, client.t("No such setting")) + } +} + +func stringToBool(str string) (result bool, err error) { + switch strings.ToLower(str) { + case "on": + result = true + case "off": + result = false + case "true": + result = true + case "false": + result = false + default: + err = errInvalidParams + } + return +} + +func nsSetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + var account string + if command == "saset" { + account = params[0] + params = params[1:] + } else { + account = client.Account() + } + + var munger settingsMunger + var finalSettings AccountSettings + var err error + switch strings.ToLower(params[0]) { + case "pass": + nsNotice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD")) + return + case "enforce": + var method NickEnforcementMethod + method, err = nickReservationFromString(params[1]) + if err != nil { + err = errInvalidParams + break + } + // updating enforcement settings is special-cased, because it requires + // an update to server.accounts.accountToMethod + finalSettings, err = server.accounts.SetEnforcementStatus(account, method) + if err == nil { + finalSettings.NickEnforcement = method // success + } + case "autoreplay-lines": + var newValue *int + if strings.ToLower(params[1]) != "default" { + val, err_ := strconv.Atoi(params[1]) + if err_ != nil || val < 0 { + err = errInvalidParams + break + } + newValue = new(int) + *newValue = val + } + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AutoreplayLines = newValue + return + } + case "bouncer": + var newValue BouncerAllowedSetting + if strings.ToLower(params[1]) == "default" { + newValue = BouncerAllowedServerDefault + } else { + var enabled bool + enabled, err = stringToBool(params[1]) + if enabled { + newValue = BouncerAllowedByUser + } else { + newValue = BouncerDisallowedByUser + } + } + if err == nil { + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AllowBouncer = newValue + return + } + } + case "autoreplay-joins": + var newValue bool + newValue, err = stringToBool(params[1]) + if err == nil { + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AutoreplayJoins = newValue + return + } + } + default: + err = errInvalidParams + } + + if munger != nil { + finalSettings, err = server.accounts.ModifyAccountSettings(account, munger) + } + + switch err { + case nil: + nsNotice(rb, client.t("Successfully changed your account settings")) + displaySetting(params[0], finalSettings, client, rb) + case errInvalidParams, errAccountDoesNotExist, errFeatureDisabled, errAccountUnverified, errAccountUpdateFailed: + nsNotice(rb, client.t(err.Error())) + default: + // unknown error + nsNotice(rb, client.t("An error occurred")) + } } func nsDropHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { @@ -568,22 +805,12 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st } func nsEnforceHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + newParams := []string{"enforce"} if len(params) == 0 { - status := server.accounts.getStoredEnforcementStatus(client.Account()) - nsNotice(rb, fmt.Sprintf(client.t("Your current nickname enforcement is: %s"), status)) + nsGetHandler(server, client, "get", newParams, rb) } else { - method, err := nickReservationFromString(params[0]) - if err != nil { - nsNotice(rb, client.t("Invalid parameters")) - return - } - err = server.accounts.SetEnforcementStatus(client.Account(), method) - if err == nil { - nsNotice(rb, client.t("Enforcement method set")) - } else { - server.logger.Error("internal", "couldn't store NS ENFORCE data", err.Error()) - nsNotice(rb, client.t("An error occurred")) - } + newParams = append(newParams, params[0]) + nsSetHandler(server, client, "set", newParams, rb) } } diff --git a/irc/services.go b/irc/services.go index b8a63f1a..9b459946 100644 --- a/irc/services.go +++ b/irc/services.go @@ -18,6 +18,7 @@ import ( type ircService struct { Name string ShortName string + prefix string CommandAliases []string Commands map[string]*serviceCommand HelpBanner string @@ -31,6 +32,7 @@ type serviceCommand struct { help string helpShort string authRequired bool + hidden bool enabled func(*Config) bool // is this command enabled in the server config? minParams int maxParams int // split into at most n params, with last param containing remaining unsplit text @@ -139,7 +141,7 @@ func servicePrivmsgHandler(service *ircService, server *Server, client *Client, func serviceRunCommand(service *ircService, server *Server, client *Client, cmd *serviceCommand, commandName string, params []string, rb *ResponseBuffer) { nick := rb.target.Nick() sendNotice := func(notice string) { - rb.Add(nil, service.Name, "NOTICE", nick, notice) + rb.Add(nil, service.prefix, "NOTICE", nick, notice) } if cmd == nil { @@ -180,7 +182,7 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par nick := rb.target.Nick() config := server.Config() sendNotice := func(notice string) { - rb.Add(nil, service.Name, "NOTICE", nick, notice) + rb.Add(nil, service.prefix, "NOTICE", nick, notice) } sendNotice(ircfmt.Unescape(fmt.Sprintf("*** $b%s HELP$b ***", service.Name))) @@ -194,7 +196,7 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par if 0 < len(commandInfo.capabs) && !client.HasRoleCapabs(commandInfo.capabs...) { continue } - if commandInfo.aliasOf != "" { + if commandInfo.aliasOf != "" || commandInfo.hidden { continue // don't show help lines for aliases } if commandInfo.enabled != nil && !commandInfo.enabled(config) { @@ -241,6 +243,8 @@ func initializeServices() { oragonoServicesByCommandAlias = make(map[string]*ircService) for serviceName, service := range OragonoServices { + service.prefix = fmt.Sprintf("%s!%s@localhost", service.Name, service.Name) + // make `/MSG ServiceName HELP` work correctly service.Commands["help"] = &servHelpCmd @@ -257,7 +261,7 @@ func initializeServices() { // force devs to write a help entry for every command for commandName, commandInfo := range service.Commands { - if commandInfo.aliasOf == "" && (commandInfo.help == "" || commandInfo.helpShort == "") { + if commandInfo.aliasOf == "" && !commandInfo.hidden && (commandInfo.help == "" || commandInfo.helpShort == "") { log.Fatal(fmt.Sprintf("help entry missing for %s command %s", serviceName, commandName)) } } diff --git a/oragono.yaml b/oragono.yaml index bd3c7534..4b483e12 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -134,7 +134,7 @@ server: # defaults to true when unset for that reason. force-trailing: true - # some clients (ZNC 1.6.x and lower, Pidgin 2.12 and lower, Adium) do not + # some clients (ZNC 1.6.x and lower, Pidgin 2.12 and lower) do not # respond correctly to SASL messages with the server name as a prefix: # https://github.com/znc/znc/issues/1212 # this works around that bug, allowing them to use SASL.