diff --git a/default.yaml b/default.yaml index 227fd0e6..40d7cfce 100644 --- a/default.yaml +++ b/default.yaml @@ -877,13 +877,18 @@ history: # (and will eventually be deleted from persistent storage, if that's enabled) expire-time: 1w - # if this is set, logged-in users cannot retrieve messages older than their - # account registration date, and logged-out users cannot retrieve messages - # older than their sign-on time (modulo grace-period, see below): - enforce-registration-date: false + # this restricts access to channel history (it can be overridden by channel + # owners). options are: 'none' (no restrictions), 'registration-time' + # (logged-in users cannot retrieve messages older than their account + # registration date, and anonymous users cannot retrieve messages older than + # their sign-on time, modulo the grace-period described below), and + # 'join-time' (users cannot retrieve messages older than the time they + # joined the channel, so only always-on clients can view history). + query-cutoff: 'none' - # but if this is set, you can retrieve messages that are up to `grace-period` - # older than the above cutoff time. this is recommended to allow logged-out + # if query-cutoff is set to 'registration-time', this allows retrieval + # of messages that are up to 'grace-period' older than the above cutoff. + # if you use 'registration-time', this is recommended to allow logged-out # users to do session resumption / query history after disconnections. grace-period: 1h diff --git a/irc/accounts.go b/irc/accounts.go index d49e7f7b..f1d0131f 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -544,7 +544,12 @@ func (am *AccountManager) setPassword(account string, password string, hasPrivs return err } -func (am *AccountManager) saveChannels(account string, channelToModes map[string]string) { +type alwaysOnChannelStatus struct { + Modes string + JoinTime int64 +} + +func (am *AccountManager) saveChannels(account string, channelToModes map[string]alwaysOnChannelStatus) { j, err := json.Marshal(channelToModes) if err != nil { am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error()) @@ -558,7 +563,7 @@ func (am *AccountManager) saveChannels(account string, channelToModes map[string }) } -func (am *AccountManager) loadChannels(account string) (channelToModes map[string]string) { +func (am *AccountManager) loadChannels(account string) (channelToModes map[string]alwaysOnChannelStatus) { key := fmt.Sprintf(keyAccountChannelToModes, account) var channelsStr string am.server.store.View(func(tx *buntdb.Tx) error { diff --git a/irc/channel.go b/irc/channel.go index 5477b050..583e87d8 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -20,7 +20,8 @@ import ( ) type ChannelSettings struct { - History HistoryStatus + History HistoryStatus + QueryCutoff HistoryCutoff } // Channel represents a channel that clients can join. @@ -109,7 +110,7 @@ func (channel *Channel) IsLoaded() bool { } func (channel *Channel) resizeHistory(config *Config) { - status, _ := channel.historyStatus(config) + status, _, _ := channel.historyStatus(config) if status == HistoryEphemeral { channel.history.Resize(config.History.ChannelLength, time.Duration(config.History.AutoresizeWindow)) } else { @@ -443,11 +444,11 @@ func (channel *Channel) regenerateMembersCache() { // Names sends the list of users joined to the channel to the given client. func (channel *Channel) Names(client *Client, rb *ResponseBuffer) { channel.stateMutex.RLock() - clientModes, isJoined := channel.members[client] + clientData, isJoined := channel.members[client] channel.stateMutex.RUnlock() isOper := client.HasMode(modes.Operator) respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper && - (!isJoined || clientModes.HighestChannelUserMode() == modes.Mode(0)) + (!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0)) isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix) isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames) @@ -463,8 +464,9 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) { nick = target.Nick() } channel.stateMutex.RLock() - modeSet := channel.members[target] + memberData, _ := channel.members[target] channel.stateMutex.RUnlock() + modeSet := memberData.modes if modeSet == nil { continue } @@ -519,7 +521,7 @@ func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) b // ClientIsAtLeast returns whether the client has at least the given channel privilege. func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool { channel.stateMutex.RLock() - clientModes := channel.members[client] + memberData := channel.members[client] founder := channel.registeredFounder channel.stateMutex.RUnlock() @@ -528,7 +530,7 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b } for _, mode := range modes.ChannelUserModes { - if clientModes.HasMode(mode) { + if memberData.modes.HasMode(mode) { return true } if mode == permission { @@ -541,35 +543,37 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b func (channel *Channel) ClientPrefixes(client *Client, isMultiPrefix bool) string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() - modes, present := channel.members[client] + memberData, present := channel.members[client] if !present { return "" } else { - return modes.Prefixes(isMultiPrefix) + return memberData.modes.Prefixes(isMultiPrefix) } } -func (channel *Channel) ClientStatus(client *Client) (present bool, cModes modes.Modes) { +func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs int64, cModes modes.Modes) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() - modes, present := channel.members[client] - return present, modes.AllModes() + memberData, present := channel.members[client] + return present, time.Unix(0, memberData.joinTime).Unix(), memberData.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) { +func (channel *Channel) alwaysOnStatus(client *Client) (chname string, status alwaysOnChannelStatus) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() chname = channel.name - modeStr = channel.members[client].String() + data := channel.members[client] + status.Modes = data.modes.String() + status.JoinTime = data.joinTime return } // overwrite any existing channel-user modes with the stored ones -func (channel *Channel) setModesForClient(client *Client, modeStr string) { +func (channel *Channel) setMemberStatus(client *Client, status alwaysOnChannelStatus) { newModes := modes.NewModeSet() - for _, mode := range modeStr { + for _, mode := range status.Modes { newModes.SetMode(modes.Mode(mode), true) } channel.stateMutex.Lock() @@ -577,14 +581,17 @@ func (channel *Channel) setModesForClient(client *Client, modeStr string) { if _, ok := channel.members[client]; !ok { return } - channel.members[client] = newModes + memberData := channel.members[client] + memberData.modes = newModes + memberData.joinTime = status.JoinTime + channel.members[client] = memberData } func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool { channel.stateMutex.RLock() founder := channel.registeredFounder - clientModes := channel.members[client] - targetModes := channel.members[target] + clientModes := channel.members[client].modes + targetModes := channel.members[target].modes channel.stateMutex.RUnlock() if founder != "" { @@ -612,7 +619,7 @@ func (channel *Channel) modeStrings(client *Client) (result []string) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() - isMember := hasPrivs || channel.members[client] != nil + isMember := hasPrivs || channel.members.Has(client) showKey := isMember && (channel.key != "") showUserLimit := channel.userLimit > 0 showForward := channel.forward != "" @@ -660,18 +667,38 @@ func (channel *Channel) IsEmpty() bool { // figure out where history is being stored: persistent, ephemeral, or neither // target is only needed if we're doing persistent history -func (channel *Channel) historyStatus(config *Config) (status HistoryStatus, target string) { +func (channel *Channel) historyStatus(config *Config) (status HistoryStatus, target string, restrictions HistoryCutoff) { if !config.History.Enabled { - return HistoryDisabled, "" + return HistoryDisabled, "", HistoryCutoffNone } channel.stateMutex.RLock() target = channel.nameCasefolded - historyStatus := channel.settings.History + settings := channel.settings registered := channel.registeredFounder != "" channel.stateMutex.RUnlock() - return channelHistoryStatus(config, registered, historyStatus), target + restrictions = settings.QueryCutoff + if restrictions == HistoryCutoffDefault { + restrictions = config.History.Restrictions.queryCutoff + } + + return channelHistoryStatus(config, registered, settings.History), target, restrictions +} + +func (channel *Channel) joinTimeCutoff(client *Client) (present bool, cutoff time.Time) { + account := client.Account() + + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + if data, ok := channel.members[client]; ok { + present = true + // report a cutoff of zero, i.e., no restriction, if the user is privileged + if !((account != "" && account == channel.registeredFounder) || data.modes.HasMode(modes.ChannelFounder) || data.modes.HasMode(modes.ChannelAdmin) || data.modes.HasMode(modes.ChannelOperator)) { + cutoff = time.Unix(0, data.joinTime) + } + } + return } func channelHistoryStatus(config *Config, registered bool, storedStatus HistoryStatus) (result HistoryStatus) { @@ -697,7 +724,7 @@ func (channel *Channel) AddHistoryItem(item history.Item, account string) (err e return } - status, target := channel.historyStatus(channel.server.Config()) + status, target, _ := channel.historyStatus(channel.server.Config()) if status == HistoryPersistent { err = channel.server.historyDB.AddChannelItem(target, item, account) } else if status == HistoryEphemeral { @@ -785,7 +812,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp givenMode = persistentMode } if givenMode != 0 { - channel.members[client].SetMode(givenMode, true) + channel.members[client].modes.SetMode(givenMode, true) } }() @@ -825,9 +852,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp for _, member := range channel.Members() { if respectAuditorium { channel.stateMutex.RLock() - memberModes, ok := channel.members[member] + memberData, ok := channel.members[member] channel.stateMutex.RUnlock() - if !ok || memberModes.HighestChannelUserMode() == modes.Mode(0) { + if !ok || memberData.modes.HighestChannelUserMode() == modes.Mode(0) { continue } } @@ -955,7 +982,7 @@ func (channel *Channel) playJoinForSession(session *Session) { func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer) { channel.stateMutex.RLock() chname := channel.name - clientModes, ok := channel.members[client] + clientData, ok := channel.members[client] channel.stateMutex.RUnlock() if !ok { @@ -974,15 +1001,15 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer) params = append(params, message) } respectAuditorium := channel.flags.HasMode(modes.Auditorium) && - clientModes.HighestChannelUserMode() == modes.Mode(0) + clientData.modes.HighestChannelUserMode() == modes.Mode(0) var cache MessageCache cache.Initialize(channel.server, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", params...) for _, member := range channel.Members() { if respectAuditorium { channel.stateMutex.RLock() - memberModes, ok := channel.members[member] + memberData, ok := channel.members[member] channel.stateMutex.RUnlock() - if !ok || memberModes.HighestChannelUserMode() == modes.Mode(0) { + if !ok || memberData.modes.HighestChannelUserMode() == modes.Mode(0) { continue } } @@ -1022,12 +1049,12 @@ func (channel *Channel) Resume(session *Session, timestamp time.Time) { func (channel *Channel) resumeAndAnnounce(session *Session) { channel.stateMutex.RLock() - modeSet := channel.members[session.client] + memberData, found := channel.members[session.client] channel.stateMutex.RUnlock() - if modeSet == nil { + if !found { return } - oldModes := modeSet.String() + oldModes := memberData.modes.String() if 0 < len(oldModes) { oldModes = "+" + oldModes } @@ -1271,8 +1298,9 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe // CanSpeak returns true if the client can speak on this channel, otherwise it returns false along with the channel mode preventing the client from speaking. func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) { channel.stateMutex.RLock() - clientModes, hasClient := channel.members[client] + memberData, hasClient := channel.members[client] channel.stateMutex.RUnlock() + clientModes := memberData.modes if !hasClient && channel.flags.HasMode(modes.NoOutside) { // TODO: enforce regular +b bans on -n channels? @@ -1347,9 +1375,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod if channel.flags.HasMode(modes.OpModerated) { channel.stateMutex.RLock() - cuModes := channel.members[client] + cuData := channel.members[client] channel.stateMutex.RUnlock() - if cuModes.HighestChannelUserMode() == modes.Mode(0) { + if cuData.modes.HighestChannelUserMode() == modes.Mode(0) { // max(statusmsg_minmode, halfop) if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice { minPrefixMode = modes.Halfop @@ -1402,9 +1430,9 @@ func (channel *Channel) applyModeToMember(client *Client, change modes.ModeChang change.Arg = target.Nick() channel.stateMutex.Lock() - modeset, exists := channel.members[target] + memberData, exists := channel.members[target] if exists { - if modeset.SetMode(change.Mode, change.Op == modes.Add) { + if memberData.modes.SetMode(change.Mode, change.Op == modes.Add) { applied = true result = change } @@ -1590,19 +1618,19 @@ func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() - clientModes := channel.members[client] - if clientModes == nil { + clientData, found := channel.members[client] + if !found { return // non-members have no friends } if !channel.flags.HasMode(modes.Auditorium) { return channel.membersCache // default behavior for members } - if clientModes.HighestChannelUserMode() != modes.Mode(0) { + if clientData.modes.HighestChannelUserMode() != modes.Mode(0) { return channel.membersCache // +v and up can see everyone in the auditorium } // without +v, your friends are those with +v and up - for member, memberModes := range channel.members { - if memberModes.HighestChannelUserMode() != modes.Mode(0) { + for member, memberData := range channel.members { + if memberData.modes.HighestChannelUserMode() != modes.Mode(0) { friends = append(friends, member) } } diff --git a/irc/chanserv.go b/irc/chanserv.go index 70ea1231..49c37c3a 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -171,6 +171,16 @@ SET modifies a channel's settings. The following settings are available:`, 2. 'ephemeral' [a limited amount of temporary history, not stored on disk] 3. 'on' [history stored in a permanent database, if available] 4. 'default' [use the server default]`, + `$bQUERY-CUTOFF$b +'query-cutoff' lets you restrict how much channel history can be retrieved +by unprivileged users. Your options are: +1. 'none' [no restrictions] +2. 'registration-time' [users can view history from after their account was + registered, plus a grace period] +3. 'join-time' [users can biew history from after they joined the + channel; note that history will be effectively + unavailable to clients that are not always-on] +4. 'default' [use the server default]`, }, enabled: chanregEnabled, minParams: 3, @@ -340,7 +350,7 @@ func csDeopHandler(service *ircService, server *Server, client *Client, command target = client } - present, cumodes := channel.ClientStatus(target) + present, _, cumodes := channel.ClientStatus(target) if !present || len(cumodes) == 0 { service.Notice(rb, client.t("Target has no privileges to remove")) return @@ -764,6 +774,13 @@ func displayChannelSetting(service *ircService, settingName string, settings Cha effectiveValue := historyEnabled(config.History.Persistent.RegisteredChannels, settings.History) service.Notice(rb, fmt.Sprintf(client.t("The stored channel history setting is: %s"), historyStatusToString(settings.History))) service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history setting is: %s"), historyStatusToString(effectiveValue))) + case "query-cutoff": + effectiveValue := settings.QueryCutoff + if effectiveValue == HistoryCutoffDefault { + effectiveValue = config.History.Restrictions.queryCutoff + } + service.Notice(rb, fmt.Sprintf(client.t("The stored channel history query cutoff setting is: %s"), historyCutoffToString(settings.QueryCutoff))) + service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history query cutoff setting is: %s"), historyCutoffToString(effectiveValue))) default: service.Notice(rb, client.t("Invalid params")) } @@ -807,6 +824,13 @@ func csSetHandler(service *ircService, server *Server, client *Client, command s } channel.SetSettings(settings) channel.resizeHistory(server.Config()) + case "query-cutoff": + settings.QueryCutoff, err = historyCutoffFromString(value) + if err != nil { + err = errInvalidParams + break + } + channel.SetSettings(settings) } switch err { diff --git a/irc/client.go b/irc/client.go index 67ceb7f6..abe05aff 100644 --- a/irc/client.go +++ b/irc/client.go @@ -408,7 +408,7 @@ func (server *Server) RunClient(conn IRCConn) { client.run(session) } -func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToModes map[string]string, lastSeen map[string]time.Time, uModes modes.Modes, realname string) { +func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen map[string]time.Time, uModes modes.Modes, realname string) { now := time.Now().UTC() config := server.Config() if lastSeen == nil && account.Settings.AutoreplayMissed { @@ -472,12 +472,12 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToModes ma // XXX set this last to avoid confusing SetNick: client.registered = true - for chname, modeStr := range channelToModes { + for chname, status := range channelToStatus { // 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) + channel.setMemberStatus(client, status) } else { server.logger.Error("internal", "could not create channel", chname) } @@ -967,7 +967,7 @@ func (session *Session) playResume() { for _, member := range channel.auditoriumFriends(client) { friends.Add(member) } - status, _ := channel.historyStatus(config) + status, _, _ := channel.historyStatus(config) if status == HistoryEphemeral { lastDiscarded := channel.history.LastDiscarded() if oldestLostMessage.Before(lastDiscarded) { @@ -2001,10 +2001,10 @@ func (client *Client) performWrite(additionalDirtyBits uint) { if (dirtyBits & IncludeChannels) != 0 { channels := client.Channels() - channelToModes := make(map[string]string, len(channels)) + channelToModes := make(map[string]alwaysOnChannelStatus, len(channels)) for _, channel := range channels { - chname, modes := channel.nameAndModes(client) - channelToModes[chname] = modes + chname, status := channel.alwaysOnStatus(client) + channelToModes[chname] = status } client.server.accounts.saveChannels(account, channelToModes) } diff --git a/irc/config.go b/irc/config.go index 159977e5..873e55ff 100644 --- a/irc/config.go +++ b/irc/config.go @@ -62,6 +62,45 @@ type listenerConfigBlock struct { HideSTS bool `yaml:"hide-sts"` } +type HistoryCutoff uint + +const ( + HistoryCutoffDefault HistoryCutoff = iota + HistoryCutoffNone + HistoryCutoffRegistrationTime + HistoryCutoffJoinTime +) + +func historyCutoffToString(restriction HistoryCutoff) string { + switch restriction { + case HistoryCutoffDefault: + return "default" + case HistoryCutoffNone: + return "none" + case HistoryCutoffRegistrationTime: + return "registration-time" + case HistoryCutoffJoinTime: + return "join-time" + default: + return "" + } +} + +func historyCutoffFromString(str string) (result HistoryCutoff, err error) { + switch strings.ToLower(str) { + case "default": + return HistoryCutoffDefault, nil + case "none", "disabled", "off", "false": + return HistoryCutoffNone, nil + case "registration-time": + return HistoryCutoffRegistrationTime, nil + case "join-time": + return HistoryCutoffJoinTime, nil + default: + return HistoryCutoffDefault, errInvalidParams + } +} + type PersistentStatus uint const ( @@ -615,9 +654,12 @@ type Config struct { ChathistoryMax int `yaml:"chathistory-maxmessages"` ZNCMax int `yaml:"znc-maxmessages"` Restrictions struct { - ExpireTime custime.Duration `yaml:"expire-time"` - EnforceRegistrationDate bool `yaml:"enforce-registration-date"` - GracePeriod custime.Duration `yaml:"grace-period"` + ExpireTime custime.Duration `yaml:"expire-time"` + // legacy key, superceded by QueryCutoff: + EnforceRegistrationDate_ bool `yaml:"enforce-registration-date"` + QueryCutoff string `yaml:"query-cutoff"` + queryCutoff HistoryCutoff + GracePeriod custime.Duration `yaml:"grace-period"` } Persistent struct { Enabled bool @@ -1358,6 +1400,19 @@ func LoadConfig(filename string) (config *Config, err error) { config.History.ZNCMax = config.History.ChathistoryMax } + if config.History.Restrictions.QueryCutoff != "" { + config.History.Restrictions.queryCutoff, err = historyCutoffFromString(config.History.Restrictions.QueryCutoff) + if err != nil { + return nil, fmt.Errorf("invalid value of history.query-restrictions: %w", err) + } + } else { + if config.History.Restrictions.EnforceRegistrationDate_ { + config.History.Restrictions.queryCutoff = HistoryCutoffRegistrationTime + } else { + config.History.Restrictions.queryCutoff = HistoryCutoffNone + } + } + config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix) config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime) diff --git a/irc/database.go b/irc/database.go index af979d23..5f6e9a59 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 = 19 + latestDbSchema = 20 keyCloakSecret = "crypto.cloak_secret" ) @@ -963,6 +963,51 @@ func schemaChangeV18To19(config *Config, tx *buntdb.Tx) error { return nil } +// #1490: start tracking join times for always-on clients +func schemaChangeV19To20(config *Config, tx *buntdb.Tx) error { + type joinData struct { + Modes string + JoinTime int64 + } + + var accounts []string + var data []string + + now := time.Now().UnixNano() + + prefix := "account.channeltomodes " + tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { + if !strings.HasPrefix(key, prefix) { + return false + } + accounts = append(accounts, strings.TrimPrefix(key, prefix)) + data = append(data, value) + return true + }) + + for i, account := range accounts { + var existingMap map[string]string + err := json.Unmarshal([]byte(data[i]), &existingMap) + if err != nil { + return err + } + newMap := make(map[string]joinData) + for channel, modeStr := range existingMap { + newMap[channel] = joinData{ + Modes: modeStr, + JoinTime: now, + } + } + serialized, err := json.Marshal(newMap) + if err != nil { + return err + } + tx.Set(prefix+account, string(serialized), nil) + } + + return nil +} + func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) { for _, change := range allChanges { if initialVersion == change.InitialVersion { @@ -1063,4 +1108,9 @@ var allChanges = []SchemaChange{ TargetVersion: 19, Changer: schemaChangeV18To19, }, + { + InitialVersion: 19, + TargetVersion: 20, + Changer: schemaChangeV19To20, + }, } diff --git a/irc/getters.go b/irc/getters.go index bb14869b..5c1a43f5 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -528,7 +528,7 @@ func (channel *Channel) Founder() string { func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) { channel.stateMutex.RLock() - clientModes := channel.members[client] + clientModes := channel.members[client].modes channel.stateMutex.RUnlock() return clientModes.HighestChannelUserMode() } diff --git a/irc/handlers.go b/irc/handlers.go index 22b5a112..e92bc38b 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -985,8 +985,8 @@ func extjwtHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re claims["channel"] = channel.Name() claims["joined"] = 0 claims["cmodes"] = []string{} - if present, cModes := channel.ClientStatus(client); present { - claims["joined"] = 1 + if present, joinTimeSecs, cModes := channel.ClientStatus(client); present { + claims["joined"] = joinTimeSecs var modeStrings []string for _, cMode := range cModes { modeStrings = append(modeStrings, string(cMode)) @@ -2660,7 +2660,7 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re } config := server.Config() - status, _ := channel.historyStatus(config) + status, _, _ := channel.historyStatus(config) if status == HistoryPersistent { rb.Add(nil, server.name, "FAIL", "RENAME", "CANNOT_RENAME", oldName, utils.SafeErrorParam(newName), client.t("Channels with persistent history cannot be renamed")) return false diff --git a/irc/server.go b/irc/server.go index cadb7c53..ed690e20 100644 --- a/irc/server.go +++ b/irc/server.go @@ -858,6 +858,7 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien var status HistoryStatus var target, correspondent string var hist *history.Buffer + restriction := HistoryCutoffNone channel = providedChannel if channel == nil { if strings.HasPrefix(query, "#") { @@ -867,12 +868,15 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien } } } + var joinTimeCutoff time.Time if channel != nil { - if !channel.hasClient(client) { + if present, cutoff := channel.joinTimeCutoff(client); present { + joinTimeCutoff = cutoff + } else { err = errInsufficientPrivs return } - status, target = channel.historyStatus(config) + status, target, restriction = channel.historyStatus(config) switch status { case HistoryEphemeral: hist = &channel.history @@ -904,15 +908,20 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien cutoff = time.Now().UTC().Add(-time.Duration(config.History.Restrictions.ExpireTime)) } // #836: registration date cutoff is always enforced for DMs - if config.History.Restrictions.EnforceRegistrationDate || channel == nil { + // either way, take the later of the two cutoffs + if restriction == HistoryCutoffRegistrationTime || channel == nil { regCutoff := client.historyCutoff() - // take the later of the two cutoffs if regCutoff.After(cutoff) { cutoff = regCutoff } + } else if restriction == HistoryCutoffJoinTime { + if joinTimeCutoff.After(cutoff) { + cutoff = joinTimeCutoff + } } + // #836 again: grace period is never applied to DMs - if !cutoff.IsZero() && channel != nil { + if !cutoff.IsZero() && channel != nil && restriction != HistoryCutoffJoinTime { cutoff = cutoff.Add(-time.Duration(config.History.Restrictions.GracePeriod)) } @@ -966,7 +975,7 @@ func (server *Server) DeleteMessage(target, msgid, accountName string) (err erro if target[0] == '#' { channel := server.channels.Get(target) if channel != nil { - if status, _ := channel.historyStatus(config); status == HistoryEphemeral { + if status, _, _ := channel.historyStatus(config); status == HistoryEphemeral { hist = &channel.history } } diff --git a/irc/types.go b/irc/types.go index 5fb8508a..176e7111 100644 --- a/irc/types.go +++ b/irc/types.go @@ -5,7 +5,11 @@ package irc -import "github.com/oragono/oragono/irc/modes" +import ( + "time" + + "github.com/oragono/oragono/irc/modes" +) type empty struct{} @@ -28,12 +32,20 @@ func (clients ClientSet) Has(client *Client) bool { return ok } +type memberData struct { + modes *modes.ModeSet + joinTime int64 +} + // MemberSet is a set of members with modes. -type MemberSet map[*Client]*modes.ModeSet +type MemberSet map[*Client]memberData // Add adds the given client to this set. func (members MemberSet) Add(member *Client) { - members[member] = modes.NewModeSet() + members[member] = memberData{ + modes: modes.NewModeSet(), + joinTime: time.Now().UnixNano(), + } } // Remove removes the given client from this set. @@ -47,15 +59,5 @@ func (members MemberSet) Has(member *Client) bool { return ok } -// AnyHasMode returns true if any of our clients has the given mode. -func (members MemberSet) AnyHasMode(mode modes.Mode) bool { - for _, modes := range members { - if modes.HasMode(mode) { - return true - } - } - return false -} - // ChannelSet is a set of channels. type ChannelSet map[*Channel]empty diff --git a/traditional.yaml b/traditional.yaml index 1436ff7f..6e7c2ec3 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -850,10 +850,14 @@ history: # (and will eventually be deleted from persistent storage, if that's enabled) expire-time: 1w - # if this is set, logged-in users cannot retrieve messages older than their - # account registration date, and logged-out users cannot retrieve messages - # older than their sign-on time (modulo grace-period, see below): - enforce-registration-date: false + # this restricts access to channel history (it can be overridden by channel + # owners). options are: 'none' (no restrictions), 'registration-time' + # (logged-in users cannot retrieve messages older than their account + # registration date, and anonymous users cannot retrieve messages older than + # their sign-on time, modulo the grace-period described below), and + # 'join-time' (users cannot retrieve messages older than the time they + # joined the channel, so only always-on clients can view history). + query-cutoff: 'none' # but if this is set, you can retrieve messages that are up to `grace-period` # older than the above cutoff time. this is recommended to allow logged-out