diff --git a/irc/accounts.go b/irc/accounts.go index 41b335c1..279f2986 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -38,11 +38,13 @@ const ( keyAccountVHost = "account.vhost %s" keyCertToAccount = "account.creds.certfp %s" keyAccountChannels = "account.channels %s" // channels registered to the account - keyAccountJoinedChannels = "account.joinedto %s" // channels a persistent client has joined keyAccountLastSeen = "account.lastseen %s" keyAccountModes = "account.modes %s" // user modes for the always-on client as a string keyAccountRealname = "account.realname %s" // client realname stored as string keyAccountSuspended = "account.suspended %s" // client realname stored as string + // for an always-on client, a map of channel names they're in to their current modes + // (not to be confused with their amodes, which a non-always-on client can have): + keyAccountChannelToModes = "account.channeltomodes %s" maxCertfpsPerAccount = 5 ) @@ -542,24 +544,34 @@ func (am *AccountManager) setPassword(account string, password string, hasPrivs return err } -func (am *AccountManager) saveChannels(account string, channels []string) { - channelsStr := strings.Join(channels, ",") - key := fmt.Sprintf(keyAccountJoinedChannels, account) +func (am *AccountManager) saveChannels(account string, channelToModes map[string]string) { + j, err := json.Marshal(channelToModes) + if err != nil { + am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error()) + return + } + jStr := string(j) + key := fmt.Sprintf(keyAccountChannelToModes, account) am.server.store.Update(func(tx *buntdb.Tx) error { - tx.Set(key, channelsStr, nil) + tx.Set(key, jStr, nil) return nil }) } -func (am *AccountManager) loadChannels(account string) (channels []string) { - key := fmt.Sprintf(keyAccountJoinedChannels, account) +func (am *AccountManager) loadChannels(account string) (channelToModes map[string]string) { + key := fmt.Sprintf(keyAccountChannelToModes, account) var channelsStr string am.server.store.View(func(tx *buntdb.Tx) error { channelsStr, _ = tx.Get(key) return nil }) - if channelsStr != "" { - return strings.Split(channelsStr, ",") + if channelsStr == "" { + return nil + } + err := json.Unmarshal([]byte(channelsStr), &channelToModes) + if err != nil { + am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error()) + return nil } return } @@ -1454,7 +1466,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error { settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount) - joinedChannelsKey := fmt.Sprintf(keyAccountJoinedChannels, casefoldedAccount) + joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount) lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount) unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount) diff --git a/irc/channel.go b/irc/channel.go index ae53b558..ed8c05f8 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -553,6 +553,30 @@ func (channel *Channel) ClientStatus(client *Client) (present bool, cModes modes return present, modes.AllModes() } +// helper for persisting channel-user modes for always-on clients; +// return the channel name and all channel-user modes for a client +func (channel *Channel) nameAndModes(client *Client) (chname string, modeStr string) { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + chname = channel.name + modeStr = channel.members[client].String() + return +} + +// overwrite any existing channel-user modes with the stored ones +func (channel *Channel) setModesForClient(client *Client, modeStr string) { + newModes := modes.NewModeSet() + for _, mode := range modeStr { + newModes.SetMode(modes.Mode(mode), true) + } + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + if _, ok := channel.members[client]; !ok { + return + } + channel.members[client] = newModes +} + func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool { channel.stateMutex.RLock() founder := channel.registeredFounder @@ -1383,6 +1407,9 @@ func (channel *Channel) applyModeToMember(client *Client, change modes.ModeChang if !exists { rb.Add(nil, client.server.name, ERR_USERNOTINCHANNEL, client.Nick(), channel.Name(), client.t("They aren't on that channel")) } + if applied { + target.markDirty(IncludeChannels) + } return } diff --git a/irc/client.go b/irc/client.go index 8f428f49..bc00f7a1 100644 --- a/irc/client.go +++ b/irc/client.go @@ -404,7 +404,7 @@ func (server *Server) RunClient(conn IRCConn) { client.run(session) } -func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, lastSeen map[string]time.Time, uModes modes.Modes, realname string) { +func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToModes map[string]string, lastSeen map[string]time.Time, uModes modes.Modes, realname string) { now := time.Now().UTC() config := server.Config() if lastSeen == nil && account.Settings.AutoreplayMissed { @@ -463,10 +463,15 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, // XXX set this last to avoid confusing SetNick: client.registered = true - for _, chname := range chnames { + for chname, modeStr := range channelToModes { // XXX we're using isSajoin=true, to make these joins succeed even without channel key // this is *probably* ok as long as the persisted memberships are accurate server.channels.Join(client, chname, "", true, nil) + if channel := server.channels.Get(chname); channel != nil { + channel.setModesForClient(client, modeStr) + } else { + server.logger.Error("internal", "could not create channel", chname) + } } if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { @@ -1967,11 +1972,12 @@ func (client *Client) performWrite(additionalDirtyBits uint) { if (dirtyBits & IncludeChannels) != 0 { channels := client.Channels() - channelNames := make([]string, len(channels)) - for i, channel := range channels { - channelNames[i] = channel.Name() + channelToModes := make(map[string]string, len(channels)) + for _, channel := range channels { + chname, modes := channel.nameAndModes(client) + channelToModes[chname] = modes } - client.server.accounts.saveChannels(account, channelNames) + client.server.accounts.saveChannels(account, channelToModes) } if (dirtyBits & IncludeLastSeen) != 0 { client.server.accounts.saveLastSeen(account, client.copyLastSeen()) diff --git a/irc/database.go b/irc/database.go index da46e822..af979d23 100644 --- a/irc/database.go +++ b/irc/database.go @@ -24,7 +24,7 @@ const ( // 'version' of the database schema keySchemaVersion = "db.version" // latest schema of the db - latestDbSchema = 18 + latestDbSchema = 19 keyCloakSecret = "crypto.cloak_secret" ) @@ -903,6 +903,66 @@ func schemaChangeV17ToV18(config *Config, tx *buntdb.Tx) error { return nil } +// #1345: persist the channel-user modes of always-on clients +func schemaChangeV18To19(config *Config, tx *buntdb.Tx) error { + channelToAmodesCache := make(map[string]map[string]modes.Mode) + joinedto := "account.joinedto " + var accounts []string + var channels [][]string + tx.AscendGreaterOrEqual("", joinedto, func(key, value string) bool { + if !strings.HasPrefix(key, joinedto) { + return false + } + accounts = append(accounts, strings.TrimPrefix(key, joinedto)) + var ch []string + if value != "" { + ch = strings.Split(value, ",") + } + channels = append(channels, ch) + return true + }) + + for i := 0; i < len(accounts); i++ { + account := accounts[i] + channels := channels[i] + tx.Delete(joinedto + account) + newValue := make(map[string]string, len(channels)) + for _, channel := range channels { + chcfname, err := CasefoldChannel(channel) + if err != nil { + continue + } + // get amodes from the channelToAmodesCache, fill if necessary + amodes, ok := channelToAmodesCache[chcfname] + if !ok { + amodeStr, _ := tx.Get("channel.accounttoumode " + chcfname) + if amodeStr != "" { + jErr := json.Unmarshal([]byte(amodeStr), &amodes) + if jErr != nil { + log.Printf("error retrieving amodes for %s: %v\n", channel, jErr) + amodes = nil + } + } + // setting/using the nil value here is ok + channelToAmodesCache[chcfname] = amodes + } + if mode, ok := amodes[account]; ok { + newValue[channel] = string(mode) + } else { + newValue[channel] = "" + } + } + newValueBytes, jErr := json.Marshal(newValue) + if jErr != nil { + log.Printf("couldn't serialize new mode values for v19: %v\n", jErr) + continue + } + tx.Set("account.channeltomodes "+account, string(newValueBytes), nil) + } + + return nil +} + func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) { for _, change := range allChanges { if initialVersion == change.InitialVersion { @@ -998,4 +1058,9 @@ var allChanges = []SchemaChange{ TargetVersion: 18, Changer: schemaChangeV17ToV18, }, + { + InitialVersion: 18, + TargetVersion: 19, + Changer: schemaChangeV18To19, + }, } diff --git a/irc/getters.go b/irc/getters.go index bbc6cf4f..cd3a939e 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -211,9 +211,9 @@ func (client *Client) SetAway(away bool, awayMessage string) (changed bool) { } func (client *Client) AlwaysOn() (alwaysOn bool) { - client.stateMutex.Lock() + client.stateMutex.RLock() alwaysOn = client.registered && client.alwaysOn - client.stateMutex.Unlock() + client.stateMutex.RUnlock() return } diff --git a/irc/import.go b/irc/import.go index 6e350b49..033c714a 100644 --- a/irc/import.go +++ b/irc/import.go @@ -20,7 +20,7 @@ const ( // XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal // (to ensure that no matter what code changes happen elsewhere, we're still producing a // db of the hardcoded version) - importDBSchemaVersion = 18 + importDBSchemaVersion = 19 ) type userImport struct {