Merge branch 'persistent.14'

This commit is contained in:
Shivaram Lingamneni
2020-02-22 23:10:52 -05:00
76 changed files with 9725 additions and 772 deletions
+116 -25
View File
@@ -33,7 +33,9 @@ const (
keyAccountSettings = "account.settings %s"
keyAccountVHost = "account.vhost %s"
keyCertToAccount = "account.creds.certfp %s"
keyAccountChannels = "account.channels %s"
keyAccountChannels = "account.channels %s" // channels registered to the account
keyAccountJoinedChannels = "account.joinedto %s" // channels a persistent client has joined
keyAccountLastSignoff = "account.lastsignoff %s"
keyVHostQueueAcctToId = "vhostQueue %s"
vhostRequestIdx = "vhostQueue"
@@ -71,6 +73,40 @@ func (am *AccountManager) Initialize(server *Server) {
config := server.Config()
am.buildNickToAccountIndex(config)
am.initVHostRequestQueue(config)
am.createAlwaysOnClients(config)
}
func (am *AccountManager) createAlwaysOnClients(config *Config) {
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
return
}
verifiedPrefix := fmt.Sprintf(keyAccountVerified, "")
am.serialCacheUpdateMutex.Lock()
defer am.serialCacheUpdateMutex.Unlock()
var accounts []string
am.server.store.View(func(tx *buntdb.Tx) error {
err := tx.AscendGreaterOrEqual("", verifiedPrefix, func(key, value string) bool {
if !strings.HasPrefix(key, verifiedPrefix) {
return false
}
account := strings.TrimPrefix(key, verifiedPrefix)
accounts = append(accounts, account)
return true
})
return err
})
for _, accountName := range accounts {
account, err := am.LoadAccount(accountName)
if err == nil && account.Verified &&
persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) {
am.server.AddAlwaysOnClient(account, am.loadChannels(accountName), am.loadLastSignoff(accountName))
}
}
}
func (am *AccountManager) buildNickToAccountIndex(config *Config) {
@@ -346,7 +382,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
callbackSpec := fmt.Sprintf("%s:%s", callbackNamespace, callbackValue)
var setOptions *buntdb.SetOptions
ttl := config.Registration.VerifyTimeout
ttl := time.Duration(config.Registration.VerifyTimeout)
if ttl != 0 {
setOptions = &buntdb.SetOptions{Expires: true, TTL: ttl}
}
@@ -477,6 +513,58 @@ 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)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, channelsStr, nil)
return nil
})
}
func (am *AccountManager) loadChannels(account string) (channels []string) {
key := fmt.Sprintf(keyAccountJoinedChannels, 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, ",")
}
return
}
func (am *AccountManager) saveLastSignoff(account string, lastSignoff time.Time) {
key := fmt.Sprintf(keyAccountLastSignoff, account)
var val string
if !lastSignoff.IsZero() {
val = strconv.FormatInt(lastSignoff.UnixNano(), 10)
}
am.server.store.Update(func(tx *buntdb.Tx) error {
if val != "" {
tx.Set(key, val, nil)
} else {
tx.Delete(key)
}
return nil
})
}
func (am *AccountManager) loadLastSignoff(account string) (lastSignoff time.Time) {
key := fmt.Sprintf(keyAccountLastSignoff, account)
var lsText string
am.server.store.View(func(tx *buntdb.Tx) error {
lsText, _ = tx.Get(key)
return nil
})
lsNum, err := strconv.ParseInt(lsText, 10, 64)
if err != nil {
return time.Unix(0, lsNum).UTC()
}
return
}
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
certfp, err = utils.NormalizeCertfp(certfp)
if err != nil {
@@ -685,7 +773,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
}
am.server.logger.Info("accounts", "client", nick, "registered account", casefoldedAccount)
raw.Verified = true
clientAccount, err := am.deserializeRawAccount(raw)
clientAccount, err := am.deserializeRawAccount(raw, casefoldedAccount)
if err != nil {
return err
}
@@ -892,13 +980,13 @@ func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount,
return
}
result, err = am.deserializeRawAccount(raw)
result.NameCasefolded = casefoldedAccount
result, err = am.deserializeRawAccount(raw, casefoldedAccount)
return
}
func (am *AccountManager) deserializeRawAccount(raw rawClientAccount) (result ClientAccount, err error) {
func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName string) (result ClientAccount, err error) {
result.Name = raw.Name
result.NameCasefolded = cfName
regTimeInt, _ := strconv.ParseInt(raw.RegisteredAt, 10, 64)
result.RegisteredAt = time.Unix(regTimeInt, 0).UTC()
e := json.Unmarshal([]byte(raw.Credentials), &result.Credentials)
@@ -976,6 +1064,8 @@ func (am *AccountManager) Unregister(account string) error {
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
vhostQueueKey := fmt.Sprintf(keyVHostQueueAcctToId, casefoldedAccount)
channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount)
joinedChannelsKey := fmt.Sprintf(keyAccountJoinedChannels, casefoldedAccount)
lastSignoffKey := fmt.Sprintf(keyAccountLastSignoff, casefoldedAccount)
var clients []*Client
@@ -1011,6 +1101,8 @@ func (am *AccountManager) Unregister(account string) error {
tx.Delete(vhostKey)
channelsStr, _ = tx.Get(channelsKey)
tx.Delete(channelsKey)
tx.Delete(joinedChannelsKey)
tx.Delete(lastSignoffKey)
_, err := tx.Delete(vhostQueueKey)
am.decrementVHostQueueCount(casefoldedAccount, err)
@@ -1087,13 +1179,13 @@ func (am *AccountManager) ChannelsForAccount(account string) (channels []string)
return unmarshalRegisteredChannels(channelStr)
}
func (am *AccountManager) AuthenticateByCertFP(client *Client, authzid string) error {
if client.certfp == "" {
func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid string) error {
if certfp == "" {
return errAccountInvalidCredentials
}
var account string
certFPKey := fmt.Sprintf(keyCertToAccount, client.certfp)
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
err := am.server.store.View(func(tx *buntdb.Tx) error {
account, _ = tx.Get(certFPKey)
@@ -1455,10 +1547,7 @@ func (am *AccountManager) applyVhostToClients(account string, result VHostInfo)
}
func (am *AccountManager) Login(client *Client, account ClientAccount) {
changed := client.SetAccountName(account.Name)
if !changed {
return
}
client.Login(account)
client.nickTimer.Touch(nil)
@@ -1468,9 +1557,6 @@ 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) {
@@ -1590,12 +1676,12 @@ func (ac *AccountCredentials) RemoveCertfp(certfp string) (err error) {
return nil
}
type BouncerAllowedSetting int
type MulticlientAllowedSetting int
const (
BouncerAllowedServerDefault BouncerAllowedSetting = iota
BouncerDisallowedByUser
BouncerAllowedByUser
MulticlientAllowedServerDefault MulticlientAllowedSetting = iota
MulticlientDisallowedByUser
MulticlientAllowedByUser
)
// controls whether/when clients without event-playback support see fake
@@ -1622,11 +1708,16 @@ func replayJoinsSettingFromString(str string) (result ReplayJoinsSetting, err er
return
}
// XXX: AllowBouncer cannot be renamed AllowMulticlient because it is stored in
// persistent JSON blobs in the database
type AccountSettings struct {
AutoreplayLines *int
NickEnforcement NickEnforcementMethod
AllowBouncer BouncerAllowedSetting
ReplayJoins ReplayJoinsSetting
AutoreplayLines *int
NickEnforcement NickEnforcementMethod
AllowBouncer MulticlientAllowedSetting
ReplayJoins ReplayJoinsSetting
AlwaysOn PersistentStatus
AutoreplayMissed bool
DMHistory HistoryStatus
}
// ClientAccount represents a user account.
@@ -1661,7 +1752,7 @@ func (am *AccountManager) logoutOfAccount(client *Client) {
return
}
client.SetAccountName("")
client.Logout()
go client.nickTimer.Touch(nil)
// dispatch account-notify
+5 -5
View File
@@ -37,6 +37,10 @@ const (
// https://ircv3.net/specs/extensions/chghost-3.2.html
ChgHost Capability = iota
// Chathistory is the proposed IRCv3 capability named "draft/chathistory":
// https://github.com/ircv3/ircv3-specifications/pull/393
Chathistory Capability = iota
// EventPlayback is the proposed IRCv3 capability named "draft/event-playback":
// https://github.com/ircv3/ircv3-specifications/pull/362
EventPlayback Capability = iota
@@ -85,10 +89,6 @@ const (
// https://ircv3.net/specs/extensions/multi-prefix-3.1.html
MultiPrefix Capability = iota
// Bouncer is the Oragono-specific capability named "oragono.io/bnc":
// https://oragono.io/bnc
Bouncer Capability = iota
// Nope is the Oragono vendor capability named "oragono.io/nope":
// https://oragono.io/nope
Nope Capability = iota
@@ -127,6 +127,7 @@ var (
"batch",
"cap-notify",
"chghost",
"draft/chathistory",
"draft/event-playback",
"draft/languages",
"draft/multiline",
@@ -139,7 +140,6 @@ var (
"labeled-response",
"message-tags",
"multi-prefix",
"oragono.io/bnc",
"oragono.io/nope",
"sasl",
"server-time",
+121 -31
View File
@@ -24,6 +24,10 @@ const (
histServMask = "HistServ!HistServ@localhost"
)
type ChannelSettings struct {
History HistoryStatus
}
// Channel represents a channel that clients can join.
type Channel struct {
flags modes.ModeSet
@@ -49,6 +53,7 @@ type Channel struct {
joinPartMutex sync.Mutex // tier 3
ensureLoaded utils.Once // manages loading stored registration info from the database
dirtyBits uint
settings ChannelSettings
}
// NewChannel creates a new channel from a `Server` and a `name`
@@ -66,9 +71,10 @@ func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channe
channel.initializeLists()
channel.writerSemaphore.Initialize(1)
channel.history.Initialize(config.History.ChannelLength, config.History.AutoresizeWindow)
channel.history.Initialize(0, 0)
if !registered {
channel.resizeHistory(config)
for _, mode := range config.Channels.defaultModes {
channel.flags.SetMode(mode, true)
}
@@ -106,8 +112,19 @@ func (channel *Channel) IsLoaded() bool {
return channel.ensureLoaded.Done()
}
func (channel *Channel) resizeHistory(config *Config) {
_, ephemeral, _ := channel.historyStatus(config)
if ephemeral {
channel.history.Resize(config.History.ChannelLength, config.History.AutoresizeWindow)
} else {
channel.history.Resize(0, 0)
}
}
// read in channel state that was persisted in the DB
func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
defer channel.resizeHistory(channel.server.Config())
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
@@ -120,6 +137,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
channel.createdTime = chanReg.RegisteredAt
channel.key = chanReg.Key
channel.userLimit = chanReg.UserLimit
channel.settings = chanReg.Settings
for _, mode := range chanReg.Modes {
channel.flags.SetMode(mode, true)
@@ -164,6 +182,10 @@ func (channel *Channel) ExportRegistration(includeFlags uint) (info RegisteredCh
}
}
if includeFlags&IncludeSettings != 0 {
info.Settings = channel.settings
}
return
}
@@ -434,7 +456,7 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
if modeSet == nil {
continue
}
if !isJoined && target.flags.HasMode(modes.Invisible) && !isOper {
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
continue
}
prefix := modeSet.Prefixes(isMultiPrefix)
@@ -564,6 +586,48 @@ func (channel *Channel) IsEmpty() bool {
return len(channel.members) == 0
}
// 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) (persistent, ephemeral bool, target string) {
if !config.History.Persistent.Enabled {
return false, config.History.Enabled, ""
}
channel.stateMutex.RLock()
target = channel.nameCasefolded
historyStatus := channel.settings.History
registered := channel.registeredFounder != ""
channel.stateMutex.RUnlock()
historyStatus = historyEnabled(config.History.Persistent.RegisteredChannels, historyStatus)
// ephemeral history: either the channel owner explicitly set the ephemeral preference,
// or persistent history is disabled for unregistered channels
if registered {
ephemeral = (historyStatus == HistoryEphemeral)
persistent = (historyStatus == HistoryPersistent)
} else {
ephemeral = config.History.Enabled && !config.History.Persistent.UnregisteredChannels
persistent = config.History.Persistent.UnregisteredChannels
}
return
}
func (channel *Channel) AddHistoryItem(item history.Item) (err error) {
if !item.IsStorable() {
return
}
persistent, ephemeral, target := channel.historyStatus(channel.server.Config())
if ephemeral {
channel.history.Add(item)
}
if persistent {
return channel.server.historyDB.AddChannelItem(target, item)
}
return nil
}
// Join joins the given client to this channel (if they can be joined).
func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *ResponseBuffer) {
details := client.Details()
@@ -618,8 +682,6 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
client.server.logger.Debug("join", fmt.Sprintf("%s joined channel %s", details.nick, chname))
var message utils.SplitMessage
givenMode := func() (givenMode modes.Mode) {
channel.joinPartMutex.Lock()
defer channel.joinPartMutex.Unlock()
@@ -643,6 +705,12 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
channel.regenerateMembersCache()
return
}()
var message utils.SplitMessage
// no history item for fake persistent joins
if rb != nil {
message = utils.MakeMessage("")
histItem := history.Item{
Type: history.Join,
@@ -651,13 +719,15 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
Message: message,
}
histItem.Params[0] = details.realname
channel.history.Add(histItem)
return
}()
channel.AddHistoryItem(histItem)
}
client.addChannel(channel)
if rb == nil {
return
}
var modestr string
if givenMode != 0 {
modestr = fmt.Sprintf("+%v", givenMode)
@@ -702,10 +772,22 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
func (channel *Channel) autoReplayHistory(client *Client, rb *ResponseBuffer, skipMsgid string) {
// autoreplay any messages as necessary
config := channel.server.Config()
var items []history.Item
var after, before time.Time
if rb.session.zncPlaybackTimes != nil && (rb.session.zncPlaybackTimes.targets == nil || rb.session.zncPlaybackTimes.targets.Has(channel.NameCasefolded())) {
items, _ = channel.history.Between(rb.session.zncPlaybackTimes.after, rb.session.zncPlaybackTimes.before, false, config.History.ChathistoryMax)
after, before = rb.session.zncPlaybackTimes.after, rb.session.zncPlaybackTimes.before
} else if !rb.session.lastSignoff.IsZero() {
// we already checked for history caps in `playReattachMessages`
after = rb.session.lastSignoff
}
if !after.IsZero() || !before.IsZero() {
_, seq, _ := channel.server.GetHistorySequence(channel, client, "")
if seq != nil {
zncMax := channel.server.Config().History.ZNCMax
items, _, _ = seq.Between(history.Selector{Time: after}, history.Selector{Time: before}, zncMax)
}
} else if !rb.session.HasHistoryCaps() {
var replayLimit int
customReplayLimit := client.AccountSettings().AutoreplayLines
@@ -719,7 +801,10 @@ func (channel *Channel) autoReplayHistory(client *Client, rb *ResponseBuffer, sk
replayLimit = channel.server.Config().History.AutoreplayOnJoin
}
if 0 < replayLimit {
items = channel.history.Latest(replayLimit)
_, seq, _ := channel.server.GetHistorySequence(channel, client, "")
if seq != nil {
items, _, _ = seq.Between(history.Selector{}, history.Selector{}, replayLimit)
}
}
}
// remove the client's own JOIN line from the replay
@@ -784,7 +869,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
}
}
channel.history.Add(history.Item{
channel.AddHistoryItem(history.Item{
Type: history.Part,
Nick: details.nickMask,
AccountName: details.accountName,
@@ -799,10 +884,9 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
// 2. Send JOIN and MODE lines to channel participants (including the new client)
// 3. Replay missed message history to the client
func (channel *Channel) Resume(session *Session, timestamp time.Time) {
now := time.Now().UTC()
channel.resumeAndAnnounce(session)
if !timestamp.IsZero() {
channel.replayHistoryForResume(session, timestamp, now)
channel.replayHistoryForResume(session, timestamp, time.Time{})
}
}
@@ -852,9 +936,17 @@ func (channel *Channel) resumeAndAnnounce(session *Session) {
}
func (channel *Channel) replayHistoryForResume(session *Session, after time.Time, before time.Time) {
items, complete := channel.history.Between(after, before, false, 0)
var items []history.Item
var complete bool
afterS, beforeS := history.Selector{Time: after}, history.Selector{Time: before}
_, seq, _ := channel.server.GetHistorySequence(channel, session.client, "")
if seq != nil {
items, complete, _ = seq.Between(afterS, beforeS, channel.server.Config().History.ZNCMax)
}
rb := NewResponseBuffer(session)
channel.replayHistoryItems(rb, items, false)
if len(items) != 0 {
channel.replayHistoryItems(rb, items, false)
}
if !complete && !session.resumeDetails.HistoryIncomplete {
// warn here if we didn't warn already
rb.Add(nil, histServMask, "NOTICE", channel.Name(), session.client.t("Some additional message history may have been lost"))
@@ -871,10 +963,7 @@ func stripMaskFromNick(nickMask string) (nick string) {
}
func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) {
if len(items) == 0 {
return
}
// send an empty batch if necessary, as per the CHATHISTORY spec
chname := channel.Name()
client := rb.target
eventPlayback := rb.session.capabilities.Has(caps.EventPlayback)
@@ -908,9 +997,9 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
case history.Join:
if eventPlayback {
if extendedJoin {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname, item.AccountName, item.Params[0])
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "JOIN", chname, item.AccountName, item.Params[0])
} else {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname)
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "JOIN", chname)
}
} else {
if !playJoinsAsPrivmsg {
@@ -926,7 +1015,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
}
case history.Part:
if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "PART", chname, item.Message.Message)
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "PART", chname, item.Message.Message)
} else {
if !playJoinsAsPrivmsg {
continue // #474
@@ -936,14 +1025,14 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
}
case history.Kick:
if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "KICK", chname, item.Params[0], item.Message.Message)
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "KICK", chname, item.Params[0], item.Message.Message)
} else {
message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message)
rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message)
}
case history.Quit:
if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "QUIT", item.Message.Message)
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "QUIT", item.Message.Message)
} else {
if !playJoinsAsPrivmsg {
continue // #474
@@ -953,7 +1042,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
}
case history.Nick:
if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "NICK", item.Params[0])
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "NICK", item.Params[0])
} else {
message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0])
rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message)
@@ -1124,11 +1213,12 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
// STATUSMSG
continue
}
if isCTCP && member.isTor {
continue // #753
}
for _, session := range member.Sessions() {
if isCTCP && session.isTor {
continue // #753
}
var tagsToUse map[string]string
if session.capabilities.Has(caps.MessageTags) {
tagsToUse = clientOnlyTags
@@ -1144,7 +1234,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
}
}
channel.history.Add(history.Item{
channel.AddHistoryItem(history.Item{
Type: histType,
Message: message,
Nick: nickmask,
@@ -1266,7 +1356,7 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
Message: message,
}
histItem.Params[0] = targetNick
channel.history.Add(histItem)
channel.AddHistoryItem(histItem)
channel.Quit(target)
}
+16 -1
View File
@@ -33,6 +33,7 @@ const (
keyChannelModes = "channel.modes %s"
keyChannelAccountToUMode = "channel.accounttoumode %s"
keyChannelUserLimit = "channel.userlimit %s"
keyChannelSettings = "channel.settings %s"
keyChannelPurged = "channel.purged %s"
)
@@ -53,6 +54,7 @@ var (
keyChannelModes,
keyChannelAccountToUMode,
keyChannelUserLimit,
keyChannelSettings,
}
)
@@ -63,6 +65,7 @@ const (
IncludeTopic
IncludeModes
IncludeLists
IncludeSettings
)
// this is an OR of all possible flags
@@ -100,6 +103,8 @@ type RegisteredChannel struct {
Excepts map[string]MaskInfo
// Invites represents the invite exceptions set on the channel.
Invites map[string]MaskInfo
// Settings are the chanserv-modifiable settings
Settings ChannelSettings
}
type ChannelPurgeRecord struct {
@@ -203,6 +208,7 @@ func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info RegisteredC
exceptlistString, _ := tx.Get(fmt.Sprintf(keyChannelExceptlist, channelKey))
invitelistString, _ := tx.Get(fmt.Sprintf(keyChannelInvitelist, channelKey))
accountToUModeString, _ := tx.Get(fmt.Sprintf(keyChannelAccountToUMode, channelKey))
settingsString, _ := tx.Get(fmt.Sprintf(keyChannelSettings, channelKey))
modeSlice := make([]modes.Mode, len(modeString))
for i, mode := range modeString {
@@ -220,6 +226,9 @@ func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info RegisteredC
accountToUMode := make(map[string]modes.Mode)
_ = json.Unmarshal([]byte(accountToUModeString), &accountToUMode)
var settings ChannelSettings
_ = json.Unmarshal([]byte(settingsString), &settings)
info = RegisteredChannel{
Name: name,
RegisteredAt: time.Unix(regTimeInt, 0).UTC(),
@@ -234,6 +243,7 @@ func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info RegisteredC
Invites: invitelist,
AccountToUMode: accountToUMode,
UserLimit: int(userLimit),
Settings: settings,
}
return nil
})
@@ -261,7 +271,7 @@ func (reg *ChannelRegistry) deleteChannel(tx *buntdb.Tx, key string, info Regist
if err == nil {
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, key))
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
registeredAt := time.Unix(regTimeInt, 0)
registeredAt := time.Unix(regTimeInt, 0).UTC()
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, key))
// to see if we're deleting the right channel, confirm the founder and the registration time
@@ -357,6 +367,11 @@ func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredCha
accountToUModeString, _ := json.Marshal(channelInfo.AccountToUMode)
tx.Set(fmt.Sprintf(keyChannelAccountToUMode, channelKey), string(accountToUModeString), nil)
}
if includeFlags&IncludeSettings != 0 {
settingsString, _ := json.Marshal(channelInfo.Settings)
tx.Set(fmt.Sprintf(keyChannelSettings, channelKey), string(settingsString), nil)
}
}
// PurgeChannel records a channel purge.
+118 -23
View File
@@ -135,6 +135,35 @@ INFO displays info about a registered channel.`,
enabled: chanregEnabled,
minParams: 1,
},
"get": {
handler: csGetHandler,
help: `Syntax: $bGET #channel <setting>$b
GET queries the current values of the channel settings. For more information
on the settings and their possible values, see HELP SET.`,
helpShort: `$bGET$b queries the current values of a channel's settings`,
enabled: chanregEnabled,
minParams: 2,
},
"set": {
handler: csSetHandler,
helpShort: `$bSET$b modifies a channel's settings`,
// these are broken out as separate strings so they can be translated separately
helpStrings: []string{
`Syntax $bSET #channel <setting> <value>$b
SET modifies a channel's settings. The following settings are available:`,
`$bHISTORY$b
'history' lets you control how channel history is stored. Your options are:
1. 'off' [no history]
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]`,
},
enabled: chanregEnabled,
minParams: 3,
},
}
)
@@ -318,6 +347,22 @@ func checkChanLimit(client *Client, rb *ResponseBuffer) (ok bool) {
return
}
func csPrivsCheck(channel RegisteredChannel, client *Client, rb *ResponseBuffer) (success bool) {
founder := channel.Founder
if founder == "" {
csNotice(rb, client.t("That channel is not registered"))
return false
}
if client.HasRoleCapabs("chanreg") {
return true
}
if founder != client.Account() {
csNotice(rb, client.t("Insufficient privileges"))
return false
}
return true
}
func csUnregisterHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
channelName := params[0]
var verificationCode string
@@ -325,31 +370,18 @@ func csUnregisterHandler(server *Server, client *Client, command string, params
verificationCode = params[1]
}
channelKey, err := CasefoldChannel(channelName)
if channelKey == "" || err != nil {
csNotice(rb, client.t("Channel name is not valid"))
return
}
channel := server.channels.Get(channelKey)
channel := server.channels.Get(channelName)
if channel == nil {
csNotice(rb, client.t("No such channel"))
return
}
founder := channel.Founder()
if founder == "" {
csNotice(rb, client.t("That channel is not registered"))
return
}
hasPrivs := client.HasRoleCapabs("chanreg") || founder == client.Account()
if !hasPrivs {
csNotice(rb, client.t("Insufficient privileges"))
return
}
info := channel.ExportRegistration(0)
channelKey := info.NameCasefolded
if !csPrivsCheck(info, client, rb) {
return
}
expectedCode := utils.ConfirmationCode(info.Name, info.RegisteredAt)
if expectedCode != verificationCode {
csNotice(rb, ircfmt.Unescape(client.t("$bWarning: unregistering this channel will remove all stored channel attributes.$b")))
@@ -357,7 +389,7 @@ func csUnregisterHandler(server *Server, client *Client, command string, params
return
}
server.channels.SetUnregistered(channelKey, founder)
server.channels.SetUnregistered(channelKey, info.Founder)
csNotice(rb, fmt.Sprintf(client.t("Channel %s is now unregistered"), channelKey))
}
@@ -367,9 +399,7 @@ func csClearHandler(server *Server, client *Client, command string, params []str
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"))
if !csPrivsCheck(channel.ExportRegistration(0), client, rb) {
return
}
@@ -563,3 +593,68 @@ func csInfoHandler(server *Server, client *Client, command string, params []stri
csNotice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder))
csNotice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123)))
}
func displayChannelSetting(settingName string, settings ChannelSettings, client *Client, rb *ResponseBuffer) {
config := client.server.Config()
switch strings.ToLower(settingName) {
case "history":
effectiveValue := historyEnabled(config.History.Persistent.RegisteredChannels, settings.History)
csNotice(rb, fmt.Sprintf(client.t("The stored channel history setting is: %s"), historyStatusToString(settings.History)))
csNotice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history setting is: %s"), historyStatusToString(effectiveValue)))
default:
csNotice(rb, client.t("Invalid params"))
}
}
func csGetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
chname, setting := params[0], params[1]
channel := server.channels.Get(chname)
if channel == nil {
csNotice(rb, client.t("No such channel"))
return
}
info := channel.ExportRegistration(IncludeSettings)
if !csPrivsCheck(info, client, rb) {
return
}
displayChannelSetting(setting, info.Settings, client, rb)
}
func csSetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
chname, setting, value := params[0], params[1], params[2]
channel := server.channels.Get(chname)
if channel == nil {
csNotice(rb, client.t("No such channel"))
return
}
info := channel.ExportRegistration(IncludeSettings)
settings := info.Settings
if !csPrivsCheck(info, client, rb) {
return
}
var err error
switch strings.ToLower(setting) {
case "history":
settings.History, err = historyStatusFromString(value)
if err != nil {
err = errInvalidParams
break
}
channel.SetSettings(settings)
channel.resizeHistory(server.Config())
}
switch err {
case nil:
csNotice(rb, client.t("Successfully changed the channel settings"))
displayChannelSetting(setting, settings, client, rb)
case errInvalidParams:
csNotice(rb, client.t("Invalid parameters"))
default:
server.logger.Error("internal", "CS SET error:", err.Error())
csNotice(rb, client.t("An error occurred"))
}
}
+303 -73
View File
@@ -29,7 +29,7 @@ import (
const (
// IdentTimeoutSeconds is how many seconds before our ident (username) check times out.
IdentTimeoutSeconds = 1.5
IRCv3TimestampFormat = "2006-01-02T15:04:05.000Z"
IRCv3TimestampFormat = utils.IRCv3TimestampFormat
)
// ResumeDetails is a place to stash data at various stages of
@@ -45,22 +45,22 @@ type ResumeDetails struct {
type Client struct {
account string
accountName string // display name of the account: uncasefolded, '*' if not logged in
accountRegDate time.Time
accountSettings AccountSettings
atime time.Time
away bool
awayMessage string
brbTimer BrbTimer
certfp string
channels ChannelSet
ctime time.Time
destroyed bool
exitedSnomaskSent bool
flags modes.ModeSet
modes modes.ModeSet
hostname string
invitedTo map[string]bool
isSTSOnly bool
isTor bool
languages []string
lastSignoff time.Time // for always-on clients, the time their last session quit
loginThrottle connection_limits.GenericThrottle
nick string
nickCasefolded string
@@ -76,17 +76,25 @@ type Client struct {
realIP net.IP
registered bool
resumeID string
saslInProgress bool
saslMechanism string
saslValue string
sentPassCommand bool
server *Server
skeleton string
sessions []*Session
stateMutex sync.RWMutex // tier 1
alwaysOn bool
username string
vhost string
history history.Buffer
dirtyBits uint
writerSemaphore utils.Semaphore // tier 1.5
}
type saslStatus struct {
mechanism string
value string
}
func (s *saslStatus) Clear() {
*s = saslStatus{}
}
// Session is an individual client connection to the server (TCP connection
@@ -102,11 +110,16 @@ type Session struct {
realIP net.IP
proxiedIP net.IP
rawHostname string
isTor bool
idletimer IdleTimer
fakelag Fakelag
destroyed uint32
certfp string
sasl saslStatus
sentPassCommand bool
batchCounter uint32
quitMessage string
@@ -120,6 +133,7 @@ type Session struct {
resumeID string
resumeDetails *ResumeDetails
zncPlaybackTimes *zncPlaybackTimes
lastSignoff time.Time
batch MultilineBatch
}
@@ -147,6 +161,13 @@ func (sd *Session) SetQuitMessage(message string) (set bool) {
}
}
func (s *Session) IP() net.IP {
if s.proxiedIP != nil {
return s.proxiedIP
}
return s.realIP
}
// returns whether the session was actively destroyed (for example, by ping
// timeout or NS GHOST).
// avoids a race condition between asynchronous idle-timing-out of sessions,
@@ -164,8 +185,7 @@ func (session *Session) SetDestroyed() {
// returns whether the client supports a smart history replay cap,
// and therefore autoreplay-on-join and similar should be suppressed
func (session *Session) HasHistoryCaps() bool {
// TODO the chathistory cap will go here as well
return session.capabilities.Has(caps.ZNCPlayback)
return session.capabilities.Has(caps.Chathistory) || session.capabilities.Has(caps.ZNCPlayback)
}
// generates a batch ID. the uniqueness requirements for this are fairly weak:
@@ -231,7 +251,6 @@ func (server *Server) RunClient(conn clientConn, proxyLine string) {
channels: make(ChannelSet),
ctime: now,
isSTSOnly: conn.Config.STSOnly,
isTor: conn.Config.Tor,
languages: server.Languages().Default(),
loginThrottle: connection_limits.GenericThrottle{
Duration: config.Accounts.LoginThrottling.Duration,
@@ -253,13 +272,14 @@ func (server *Server) RunClient(conn clientConn, proxyLine string) {
ctime: now,
atime: now,
realIP: realIP,
isTor: conn.Config.Tor,
}
client.sessions = []*Session{session}
if conn.Config.TLSConfig != nil {
client.SetMode(modes.TLS, true)
// error is not useful to us here anyways so we can ignore it
client.certfp, _ = socket.CertFP()
session.certfp, _ = socket.CertFP()
}
if conn.Config.Tor {
@@ -272,7 +292,7 @@ func (server *Server) RunClient(conn clientConn, proxyLine string) {
client.rawHostname = session.rawHostname
} else {
remoteAddr := conn.Conn.RemoteAddr()
if utils.AddrIsLocal(remoteAddr) {
if realIP.IsLoopback() || utils.IPInNets(realIP, config.Server.secureNets) {
// treat local connections as secure (may be overridden later by WEBIRC)
client.SetMode(modes.TLS, true)
}
@@ -286,10 +306,66 @@ func (server *Server) RunClient(conn clientConn, proxyLine string) {
client.run(session, proxyLine)
}
func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, lastSignoff time.Time) {
now := time.Now().UTC()
config := server.Config()
client := &Client{
atime: now,
channels: make(ChannelSet),
ctime: now,
languages: server.Languages().Default(),
server: server,
// TODO figure out how to set these on reattach?
username: "~user",
rawHostname: server.name,
realIP: utils.IPv4LoopbackAddress,
alwaysOn: true,
lastSignoff: lastSignoff,
}
client.SetMode(modes.TLS, true)
client.writerSemaphore.Initialize(1)
client.history.Initialize(0, 0)
client.brbTimer.Initialize(client)
server.accounts.Login(client, account)
client.resizeHistory(config)
_, err := server.clients.SetNick(client, nil, account.Name)
if err != nil {
server.logger.Error("internal", "could not establish always-on client", account.Name, err.Error())
return
} else {
server.logger.Debug("accounts", "established always-on client", account.Name)
}
// XXX set this last to avoid confusing SetNick:
client.registered = true
for _, chname := range chnames {
// 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)
}
}
func (client *Client) resizeHistory(config *Config) {
_, ephemeral, _ := client.historyStatus(config)
if ephemeral {
client.history.Resize(config.History.ClientLength, config.History.AutoresizeWindow)
} else {
client.history.Resize(0, 0)
}
}
// resolve an IP to an IRC-ready hostname, using reverse DNS, forward-confirming if necessary,
// and sending appropriate notices to the client
func (client *Client) lookupHostname(session *Session, overwrite bool) {
if client.isTor {
if session.isTor {
return
} // else: even if cloaking is enabled, look up the real hostname to show to operators
@@ -384,18 +460,18 @@ const (
authFailSaslRequired
)
func (client *Client) isAuthorized(config *Config) AuthOutcome {
func (client *Client) isAuthorized(config *Config, session *Session) AuthOutcome {
saslSent := client.account != ""
// PASS requirement
if (config.Server.passwordBytes != nil) && !client.sentPassCommand && !(config.Accounts.SkipServerPassword && saslSent) {
if (config.Server.passwordBytes != nil) && !session.sentPassCommand && !(config.Accounts.SkipServerPassword && saslSent) {
return authFailPass
}
// Tor connections may be required to authenticate with SASL
if client.isTor && config.Server.TorListeners.RequireSasl && !saslSent {
if session.isTor && config.Server.TorListeners.RequireSasl && !saslSent {
return authFailTorSaslRequired
}
// finally, enforce require-sasl
if config.Accounts.RequireSasl.Enabled && !saslSent && !utils.IPInNets(client.IP(), config.Accounts.RequireSasl.exemptedNets) {
if config.Accounts.RequireSasl.Enabled && !saslSent && !utils.IPInNets(session.IP(), config.Accounts.RequireSasl.exemptedNets) {
return authFailSaslRequired
}
return authSuccess
@@ -572,9 +648,13 @@ func (client *Client) run(session *Session, proxyLine string) {
func (client *Client) playReattachMessages(session *Session) {
client.server.playRegistrationBurst(session)
hasHistoryCaps := session.HasHistoryCaps()
for _, channel := range session.client.Channels() {
channel.playJoinForSession(session)
// clients should receive autoreplay-on-join lines, if applicable;
// clients should receive autoreplay-on-join lines, if applicable:
if hasHistoryCaps {
continue
}
// if they negotiated znc.in/playback or chathistory, they will receive nothing,
// because those caps disable autoreplay-on-join and they haven't sent the relevant
// *playback PRIVMSG or CHATHISTORY command yet
@@ -582,6 +662,12 @@ func (client *Client) playReattachMessages(session *Session) {
channel.autoReplayHistory(client, rb, "")
rb.Send(true)
}
if !session.lastSignoff.IsZero() && !hasHistoryCaps {
rb := NewResponseBuffer(session)
zncPlayPrivmsgs(client, rb, session.lastSignoff, time.Time{})
rb.Send(true)
}
session.lastSignoff = time.Time{}
}
//
@@ -634,11 +720,6 @@ func (session *Session) tryResume() (success bool) {
return
}
if oldClient.isTor != client.isTor {
session.Send(nil, server.name, "FAIL", "RESUME", "INSECURE_SESSION", client.t("Cannot resume connection from Tor to non-Tor or vice versa"))
return
}
err := server.clients.Resume(oldClient, session)
if err != nil {
session.Send(nil, server.name, "FAIL", "RESUME", "CANNOT_RESUME", client.t("Cannot resume connection"))
@@ -657,37 +738,45 @@ func (session *Session) tryResume() (success bool) {
func (session *Session) playResume() {
client := session.client
server := client.server
config := server.Config()
friends := make(ClientSet)
oldestLostMessage := time.Now().UTC()
var oldestLostMessage time.Time
// work out how much time, if any, is not covered by history buffers
// assume that a persistent buffer covers the whole resume period
for _, channel := range client.Channels() {
for _, member := range channel.Members() {
friends.Add(member)
}
_, ephemeral, _ := channel.historyStatus(config)
if ephemeral {
lastDiscarded := channel.history.LastDiscarded()
if lastDiscarded.Before(oldestLostMessage) {
if oldestLostMessage.Before(lastDiscarded) {
oldestLostMessage = lastDiscarded
}
}
}
privmsgMatcher := func(item history.Item) bool {
return item.Type == history.Privmsg || item.Type == history.Notice || item.Type == history.Tagmsg
_, cEphemeral, _ := client.historyStatus(config)
if cEphemeral {
lastDiscarded := client.history.LastDiscarded()
if oldestLostMessage.Before(lastDiscarded) {
oldestLostMessage = lastDiscarded
}
}
privmsgHistory := client.history.Match(privmsgMatcher, false, 0)
lastDiscarded := client.history.LastDiscarded()
if lastDiscarded.Before(oldestLostMessage) {
oldestLostMessage = lastDiscarded
}
for _, item := range privmsgHistory {
sender := server.clients.Get(stripMaskFromNick(item.Nick))
if sender != nil {
friends.Add(sender)
_, privmsgSeq, _ := server.GetHistorySequence(nil, client, "*")
if privmsgSeq != nil {
privmsgs, _, _ := privmsgSeq.Between(history.Selector{}, history.Selector{}, config.History.ClientLength)
for _, item := range privmsgs {
sender := server.clients.Get(stripMaskFromNick(item.Nick))
if sender != nil {
friends.Add(sender)
}
}
}
timestamp := session.resumeDetails.Timestamp
gap := lastDiscarded.Sub(timestamp)
gap := oldestLostMessage.Sub(timestamp)
session.resumeDetails.HistoryIncomplete = gap > 0 || timestamp.IsZero()
gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion
@@ -723,10 +812,12 @@ func (session *Session) playResume() {
}
}
if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() {
session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds))
} else {
session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Resume may have lost some message history"))
if session.resumeDetails.HistoryIncomplete {
if !timestamp.IsZero() {
session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds))
} else {
session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Resume may have lost some message history"))
}
}
session.Send(nil, client.server.name, "RESUME", "SUCCESS", details.nick)
@@ -738,24 +829,27 @@ func (session *Session) playResume() {
}
// replay direct PRIVSMG history
if !timestamp.IsZero() {
now := time.Now().UTC()
items, complete := client.history.Between(timestamp, now, false, 0)
rb := NewResponseBuffer(client.Sessions()[0])
client.replayPrivmsgHistory(rb, items, complete)
rb.Send(true)
if !timestamp.IsZero() && privmsgSeq != nil {
after := history.Selector{Time: timestamp}
items, complete, _ := privmsgSeq.Between(after, history.Selector{}, config.History.ZNCMax)
if len(items) != 0 {
rb := NewResponseBuffer(session)
client.replayPrivmsgHistory(rb, items, "", complete)
rb.Send(true)
}
}
session.resumeDetails = nil
}
func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) {
func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string, complete bool) {
var batchID string
details := client.Details()
nick := details.nick
if 0 < len(items) {
batchID = rb.StartNestedHistoryBatch(nick)
if target == "" {
target = nick
}
batchID = rb.StartNestedHistoryBatch(target)
allowTags := rb.session.capabilities.Has(caps.MessageTags)
for _, item := range items {
@@ -778,12 +872,16 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
if allowTags {
tags = item.Tags
}
if item.Params[0] == "" {
// this message was sent *to* the client from another nick
// XXX: Params[0] is the message target. if the source of this message is an in-memory
// buffer, then it's "" for an incoming message and the recipient's nick for an outgoing
// message. if the source of the message is mysql, then mysql only sees one copy of the
// message, and it's the version with the recipient's nick filled in. so this is an
// incoming message if Params[0] (the recipient's nick) equals the client's nick:
if item.Params[0] == "" || item.Params[0] == nick {
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message)
} else {
// this message was sent *from* the client to another nick; the target is item.Params[0]
// substitute the client's current nickmask in case they changed nick
// substitute client's current nickmask in case client changed nick
rb.AddSplitMessageFromClient(details.nickMask, item.AccountName, tags, command, item.Params[0], item.Message)
}
}
@@ -875,7 +973,7 @@ func (client *Client) HasRoleCapabs(capabs ...string) bool {
// ModeString returns the mode string for this client.
func (client *Client) ModeString() (str string) {
return "+" + client.flags.String()
return "+" + client.modes.String()
}
// Friends refers to clients that share a channel with this client.
@@ -1053,6 +1151,12 @@ func (client *Client) Quit(message string, session *Session) {
// has no more sessions.
func (client *Client) destroy(session *Session) {
var sessionsToDestroy []*Session
var lastSignoff time.Time
if session != nil {
lastSignoff = session.idletimer.LastTouch()
} else {
lastSignoff = time.Now().UTC()
}
client.stateMutex.Lock()
details := client.detailsNoMutex()
@@ -1060,6 +1164,8 @@ func (client *Client) destroy(session *Session) {
brbAt := client.brbTimer.brbAt
wasReattach := session != nil && session.client != client
sessionRemoved := false
registered := client.registered
alwaysOn := client.alwaysOn
var remainingSessions int
if session == nil {
sessionsToDestroy = client.sessions
@@ -1074,15 +1180,25 @@ func (client *Client) destroy(session *Session) {
// should we destroy the whole client this time?
// BRB is not respected if this is a destroy of the whole client (i.e., session == nil)
brbEligible := session != nil && (brbState == BrbEnabled || brbState == BrbSticky)
brbEligible := session != nil && (brbState == BrbEnabled || alwaysOn)
shouldDestroy := !client.destroyed && remainingSessions == 0 && !brbEligible
if shouldDestroy {
// if it's our job to destroy it, don't let anyone else try
client.destroyed = true
}
if alwaysOn && remainingSessions == 0 {
client.lastSignoff = lastSignoff
client.dirtyBits |= IncludeLastSignoff
} else {
lastSignoff = time.Time{}
}
exitedSnomaskSent := client.exitedSnomaskSent
client.stateMutex.Unlock()
if !lastSignoff.IsZero() {
client.wakeWriter()
}
// destroy all applicable sessions:
var quitMessage string
for _, session := range sessionsToDestroy {
@@ -1099,7 +1215,7 @@ func (client *Client) destroy(session *Session) {
// remove from connection limits
var source string
if client.isTor {
if session.isTor {
client.server.torLimiter.RemoveClient()
source = "tor"
} else {
@@ -1113,11 +1229,33 @@ func (client *Client) destroy(session *Session) {
client.server.logger.Info("localconnect-ip", fmt.Sprintf("disconnecting session of %s from %s", details.nick, source))
}
// decrement stats if we have no more sessions, even if the client will not be destroyed
if shouldDestroy || remainingSessions == 0 {
invisible := client.HasMode(modes.Invisible)
operator := client.HasMode(modes.LocalOperator) || client.HasMode(modes.Operator)
client.server.stats.Remove(registered, invisible, operator)
}
// do not destroy the client if it has either remaining sessions, or is BRB'ed
if !shouldDestroy {
return
}
splitQuitMessage := utils.MakeMessage(quitMessage)
quitItem := history.Item{
Type: history.Quit,
Nick: details.nickMask,
AccountName: details.accountName,
Message: splitQuitMessage,
}
var channels []*Channel
// use a defer here to avoid writing to mysql while holding the destroy semaphore:
defer func() {
for _, channel := range channels {
channel.AddHistoryItem(quitItem)
}
}()
// see #235: deduplicating the list of PART recipients uses (comparatively speaking)
// a lot of RAM, so limit concurrency to avoid thrashing
client.server.semaphores.ClientDestroy.Acquire()
@@ -1127,7 +1265,6 @@ func (client *Client) destroy(session *Session) {
client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", details.nick))
}
registered := client.Registered()
if registered {
client.server.whoWas.Append(client.WhoWas())
}
@@ -1141,18 +1278,12 @@ func (client *Client) destroy(session *Session) {
// clean up monitor state
client.server.monitorManager.RemoveAll(client)
splitQuitMessage := utils.MakeMessage(quitMessage)
// clean up channels
// (note that if this is a reattach, client has no channels and therefore no friends)
friends := make(ClientSet)
for _, channel := range client.Channels() {
channels = client.Channels()
for _, channel := range channels {
channel.Quit(client)
channel.history.Add(history.Item{
Type: history.Quit,
Nick: details.nickMask,
AccountName: details.accountName,
Message: splitQuitMessage,
})
for _, member := range channel.Members() {
friends.Add(member)
}
@@ -1168,9 +1299,6 @@ func (client *Client) destroy(session *Session) {
client.server.accounts.Logout(client)
client.server.stats.Remove(registered, client.HasMode(modes.Invisible),
client.HasMode(modes.Operator) || client.HasMode(modes.LocalOperator))
// this happens under failure to return from BRB
if quitMessage == "" {
if brbState == BrbDead && !brbAt.IsZero() {
@@ -1196,11 +1324,10 @@ func (client *Client) destroy(session *Session) {
// SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client.
// Adds account-tag to the line as well.
func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask, accountName string, tags map[string]string, command, target string, message utils.SplitMessage) {
// TODO no maxline support
if message.Is512() {
session.sendFromClientInternal(blocking, message.Time, message.Msgid, nickmask, accountName, tags, command, target, message.Message)
} else {
if message.IsMultiline() && session.capabilities.Has(caps.Multiline) {
if session.capabilities.Has(caps.Multiline) {
for _, msg := range session.composeMultilineBatch(nickmask, accountName, tags, command, target, message) {
session.SendRawMessage(msg, blocking)
}
@@ -1366,13 +1493,23 @@ func (session *Session) Notice(text string) {
func (client *Client) addChannel(channel *Channel) {
client.stateMutex.Lock()
client.channels[channel] = true
alwaysOn := client.alwaysOn
client.stateMutex.Unlock()
if alwaysOn {
client.markDirty(IncludeChannels)
}
}
func (client *Client) removeChannel(channel *Channel) {
client.stateMutex.Lock()
delete(client.channels, channel)
alwaysOn := client.alwaysOn
client.stateMutex.Unlock()
if alwaysOn {
client.markDirty(IncludeChannels)
}
}
// Records that the client has been invited to join an invite-only channel
@@ -1401,11 +1538,11 @@ func (client *Client) CheckInvited(casefoldedChannel string) (invited bool) {
// Implements auto-oper by certfp (scans for an auto-eligible operator block that matches
// the client's cert, then applies it).
func (client *Client) attemptAutoOper(session *Session) {
if client.certfp == "" || client.HasMode(modes.Operator) {
if session.certfp == "" || client.HasMode(modes.Operator) {
return
}
for _, oper := range client.server.Config().operators {
if oper.Auto && oper.Pass == nil && oper.Fingerprint != "" && oper.Fingerprint == client.certfp {
if oper.Auto && oper.Pass == nil && oper.Fingerprint != "" && oper.Fingerprint == session.certfp {
rb := NewResponseBuffer(session)
applyOper(client, oper, rb)
rb.Send(true)
@@ -1413,3 +1550,96 @@ func (client *Client) attemptAutoOper(session *Session) {
}
}
}
func (client *Client) historyStatus(config *Config) (persistent, ephemeral bool, target string) {
if !config.History.Enabled {
return
} else if !config.History.Persistent.Enabled {
ephemeral = true
return
}
client.stateMutex.RLock()
alwaysOn := client.alwaysOn
historyStatus := client.accountSettings.DMHistory
target = client.nickCasefolded
client.stateMutex.RUnlock()
if !alwaysOn {
ephemeral = true
return
}
historyStatus = historyEnabled(config.History.Persistent.DirectMessages, historyStatus)
ephemeral = (historyStatus == HistoryEphemeral)
persistent = (historyStatus == HistoryPersistent)
return
}
// these are bit flags indicating what part of the client status is "dirty"
// and needs to be read from memory and written to the db
// TODO add a dirty flag for lastSignoff
const (
IncludeChannels uint = 1 << iota
IncludeLastSignoff
)
func (client *Client) markDirty(dirtyBits uint) {
client.stateMutex.Lock()
alwaysOn := client.alwaysOn
client.dirtyBits = client.dirtyBits | dirtyBits
client.stateMutex.Unlock()
if alwaysOn {
client.wakeWriter()
}
}
func (client *Client) wakeWriter() {
if client.writerSemaphore.TryAcquire() {
go client.writeLoop()
}
}
func (client *Client) writeLoop() {
for {
client.performWrite()
client.writerSemaphore.Release()
client.stateMutex.RLock()
isDirty := client.dirtyBits != 0
client.stateMutex.RUnlock()
if !isDirty || !client.writerSemaphore.TryAcquire() {
return
}
}
}
func (client *Client) performWrite() {
client.stateMutex.Lock()
dirtyBits := client.dirtyBits
client.dirtyBits = 0
account := client.account
client.stateMutex.Unlock()
if account == "" {
client.server.logger.Error("internal", "attempting to persist logged-out client", client.Nick())
return
}
if (dirtyBits & IncludeChannels) != 0 {
channels := client.Channels()
channelNames := make([]string, len(channels))
for i, channel := range channels {
channelNames[i] = channel.Name()
}
client.server.accounts.saveChannels(account, channelNames)
}
if (dirtyBits & IncludeLastSignoff) != 0 {
client.stateMutex.RLock()
lastSignoff := client.lastSignoff
client.stateMutex.RUnlock()
client.server.accounts.saveLastSignoff(account, lastSignoff)
}
}
+53 -23
View File
@@ -105,7 +105,8 @@ func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err e
return errNickMissing
}
if !oldClient.AddSession(session) {
success, _, _ := oldClient.AddSession(session)
if !success {
return errNickMissing
}
@@ -113,35 +114,51 @@ func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err e
}
// SetNick sets a client's nickname, validating it against nicknames in use
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string) error {
if len(newNick) > client.server.Config().Limits.NickLen {
return errNicknameInvalid
}
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string) (setNick string, err error) {
config := client.server.Config()
newcfnick, err := CasefoldName(newNick)
if err != nil {
return errNicknameInvalid
return "", errNicknameInvalid
}
if len(newNick) > config.Limits.NickLen || len(newcfnick) > config.Limits.NickLen {
return "", errNicknameInvalid
}
newSkeleton, err := Skeleton(newNick)
if err != nil {
return errNicknameInvalid
return "", errNicknameInvalid
}
if restrictedCasefoldedNicks[newcfnick] || restrictedSkeletons[newSkeleton] {
return errNicknameInvalid
return "", errNicknameInvalid
}
reservedAccount, method := client.server.accounts.EnforcementStatus(newcfnick, newSkeleton)
account := client.Account()
config := client.server.Config()
client.stateMutex.RLock()
account := client.account
accountName := client.accountName
settings := client.accountSettings
registered := client.registered
realname := client.realname
client.stateMutex.RUnlock()
// recompute this (client.alwaysOn is not set for unregistered clients):
alwaysOn := account != "" && persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, settings.AlwaysOn)
if alwaysOn && registered {
return "", errCantChangeNick
}
var bouncerAllowed bool
if config.Accounts.Bouncer.Enabled {
if session != nil && session.capabilities.Has(caps.Bouncer) {
if config.Accounts.Multiclient.Enabled {
if alwaysOn {
// ignore the pre-reg nick, force a reattach
newNick = accountName
newcfnick = account
bouncerAllowed = true
} else {
settings := client.AccountSettings()
if config.Accounts.Bouncer.AllowedByDefault && settings.AllowBouncer != BouncerDisallowedByUser {
if config.Accounts.Multiclient.AllowedByDefault && settings.AllowBouncer != MulticlientDisallowedByUser {
bouncerAllowed = true
} else if settings.AllowBouncer == BouncerAllowedByUser {
} else if settings.AllowBouncer == MulticlientAllowedByUser {
bouncerAllowed = true
}
}
@@ -154,28 +171,41 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
// the client may just be changing case
if currentClient != nil && currentClient != client && session != nil {
// these conditions forbid reattaching to an existing session:
if client.Registered() || !bouncerAllowed || account == "" || account != currentClient.Account() || client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
return errNicknameInUse
if registered || !bouncerAllowed || account == "" || account != currentClient.Account() || client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
return "", errNicknameInUse
}
if !currentClient.AddSession(session) {
return errNicknameInUse
reattachSuccessful, numSessions, lastSignoff := currentClient.AddSession(session)
if !reattachSuccessful {
return "", errNicknameInUse
}
if numSessions == 1 {
invisible := client.HasMode(modes.Invisible)
operator := client.HasMode(modes.Operator) || client.HasMode(modes.LocalOperator)
client.server.stats.AddRegistered(invisible, operator)
}
session.lastSignoff = lastSignoff
// XXX SetNames only changes names if they are unset, so the realname change only
// takes effect on first attach to an always-on client (good), but the user/ident
// change is always a no-op (bad). we could make user/ident act the same way as
// realname, but then we'd have to send CHGHOST and i don't want to deal with that
// for performance reasons
currentClient.SetNames("user", realname, true)
// successful reattach!
return nil
return newNick, nil
}
// analogous checks for skeletons
skeletonHolder := clients.bySkeleton[newSkeleton]
if skeletonHolder != nil && skeletonHolder != client {
return errNicknameInUse
return "", errNicknameInUse
}
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account {
return errNicknameReserved
return "", errNicknameReserved
}
clients.removeInternal(client)
clients.byNick[newcfnick] = client
clients.bySkeleton[newSkeleton] = client
client.updateNick(newNick, newcfnick, newSkeleton)
return nil
return newNick, nil
}
func (clients *ClientManager) AllClients() (result []*Client) {
+7 -6
View File
@@ -54,6 +54,12 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
return cmd.handler(server, client, msg, rb)
}()
// most servers do this only for PING/PONG, but we'll do it for any command:
if client.registered {
// touch even if `exiting`, so we record the time of a QUIT accurately
session.idletimer.Touch()
}
if exiting {
return
}
@@ -63,11 +69,6 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
exiting = server.tryRegister(client, session)
}
// most servers do this only for PING/PONG, but we'll do it for any command:
if client.registered {
session.idletimer.Touch()
}
if client.registered && !cmd.leaveClientIdle {
client.Active(session)
}
@@ -109,7 +110,7 @@ func init() {
},
"CHATHISTORY": {
handler: chathistoryHandler,
minParams: 3,
minParams: 4,
},
"DEBUG": {
handler: debugHandler,
+212 -29
View File
@@ -28,6 +28,7 @@ import (
"github.com/oragono/oragono/irc/ldap"
"github.com/oragono/oragono/irc/logger"
"github.com/oragono/oragono/irc/modes"
"github.com/oragono/oragono/irc/mysql"
"github.com/oragono/oragono/irc/passwd"
"github.com/oragono/oragono/irc/utils"
"gopkg.in/yaml.v2"
@@ -61,6 +62,157 @@ type listenerConfig struct {
ProxyBeforeTLS bool
}
type PersistentStatus uint
const (
PersistentUnspecified PersistentStatus = iota
PersistentDisabled
PersistentOptIn
PersistentOptOut
PersistentMandatory
)
func persistentStatusToString(status PersistentStatus) string {
switch status {
case PersistentUnspecified:
return "default"
case PersistentDisabled:
return "disabled"
case PersistentOptIn:
return "opt-in"
case PersistentOptOut:
return "opt-out"
case PersistentMandatory:
return "mandatory"
default:
return ""
}
}
func persistentStatusFromString(status string) (PersistentStatus, error) {
switch strings.ToLower(status) {
case "default":
return PersistentUnspecified, nil
case "":
return PersistentDisabled, nil
case "opt-in":
return PersistentOptIn, nil
case "opt-out":
return PersistentOptOut, nil
case "mandatory":
return PersistentMandatory, nil
default:
b, err := utils.StringToBool(status)
if b {
return PersistentMandatory, err
} else {
return PersistentDisabled, err
}
}
}
func (ps *PersistentStatus) UnmarshalYAML(unmarshal func(interface{}) error) error {
var orig string
var err error
if err = unmarshal(&orig); err != nil {
return err
}
result, err := persistentStatusFromString(orig)
if err == nil {
if result == PersistentUnspecified {
result = PersistentDisabled
}
*ps = result
}
return err
}
func persistenceEnabled(serverSetting, clientSetting PersistentStatus) (enabled bool) {
if serverSetting == PersistentDisabled {
return false
} else if serverSetting == PersistentMandatory {
return true
} else if clientSetting == PersistentDisabled {
return false
} else if clientSetting == PersistentMandatory {
return true
} else if serverSetting == PersistentOptOut {
return true
} else {
return false
}
}
type HistoryStatus uint
const (
HistoryDefault HistoryStatus = iota
HistoryDisabled
HistoryEphemeral
HistoryPersistent
)
func historyStatusFromString(str string) (status HistoryStatus, err error) {
switch strings.ToLower(str) {
case "default":
return HistoryDefault, nil
case "ephemeral":
return HistoryEphemeral, nil
case "persistent":
return HistoryPersistent, nil
default:
b, err := utils.StringToBool(str)
if b {
return HistoryPersistent, err
} else {
return HistoryDisabled, err
}
}
}
func historyStatusToString(status HistoryStatus) string {
switch status {
case HistoryDefault:
return "default"
case HistoryDisabled:
return "disabled"
case HistoryEphemeral:
return "ephemeral"
case HistoryPersistent:
return "persistent"
default:
return ""
}
}
func historyEnabled(serverSetting PersistentStatus, localSetting HistoryStatus) (result HistoryStatus) {
if serverSetting == PersistentDisabled {
return HistoryDisabled
} else if serverSetting == PersistentMandatory {
return HistoryPersistent
} else if serverSetting == PersistentOptOut {
if localSetting == HistoryDefault {
return HistoryPersistent
} else {
return localSetting
}
} else if serverSetting == PersistentOptIn {
if localSetting >= HistoryEphemeral {
return localSetting
} else {
return HistoryDisabled
}
} else {
return HistoryDisabled
}
}
type MulticlientConfig struct {
Enabled bool
AllowedByDefault bool `yaml:"allowed-by-default"`
AlwaysOn PersistentStatus `yaml:"always-on"`
}
type AccountConfig struct {
Registration AccountRegistrationConfig
AuthenticationEnabled bool `yaml:"authentication-enabled"`
@@ -77,19 +229,17 @@ type AccountConfig struct {
} `yaml:"login-throttling"`
SkipServerPassword bool `yaml:"skip-server-password"`
NickReservation NickReservationConfig `yaml:"nick-reservation"`
Bouncer struct {
Enabled bool
AllowedByDefault bool `yaml:"allowed-by-default"`
}
VHosts VHostConfig
Multiclient MulticlientConfig
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
VHosts VHostConfig
}
// AccountRegistrationConfig controls account registration.
type AccountRegistrationConfig struct {
Enabled bool
EnabledCallbacks []string `yaml:"enabled-callbacks"`
EnabledCredentialTypes []string `yaml:"-"`
VerifyTimeout time.Duration `yaml:"verify-timeout"`
EnabledCallbacks []string `yaml:"enabled-callbacks"`
EnabledCredentialTypes []string `yaml:"-"`
VerifyTimeout custime.Duration `yaml:"verify-timeout"`
Callbacks struct {
Mailto struct {
Server string
@@ -117,7 +267,7 @@ type VHostConfig struct {
UserRequests struct {
Enabled bool
Channel string
Cooldown time.Duration
Cooldown custime.Duration
} `yaml:"user-requests"`
OfferList []string `yaml:"offer-list"`
}
@@ -260,18 +410,17 @@ type Limits struct {
// STSConfig controls the STS configuration/
type STSConfig struct {
Enabled bool
Duration time.Duration `yaml:"duration-real"`
DurationString string `yaml:"duration"`
Port int
Preload bool
STSOnlyBanner string `yaml:"sts-only-banner"`
bannerLines []string
Enabled bool
Duration custime.Duration
Port int
Preload bool
STSOnlyBanner string `yaml:"sts-only-banner"`
bannerLines []string
}
// Value returns the STS value to advertise in CAP
func (sts *STSConfig) Value() string {
val := fmt.Sprintf("duration=%d", int(sts.Duration.Seconds()))
val := fmt.Sprintf("duration=%d", int(time.Duration(sts.Duration).Seconds()))
if sts.Enabled && sts.Port > 0 {
val += fmt.Sprintf(",port=%d", sts.Port)
}
@@ -337,6 +486,8 @@ type Config struct {
isupport isupport.List
IPLimits connection_limits.LimiterConfig `yaml:"ip-limits"`
Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"`
SecureNetDefs []string `yaml:"secure-nets"`
secureNets []net.IPNet
supportedCaps *caps.Set
capValues caps.Values
Casemapping Casemapping
@@ -353,6 +504,7 @@ type Config struct {
Datastore struct {
Path string
AutoUpgrade bool
MySQL mysql.Config
}
Accounts AccountConfig
@@ -392,6 +544,18 @@ type Config struct {
AutoresizeWindow time.Duration `yaml:"autoresize-window"`
AutoreplayOnJoin int `yaml:"autoreplay-on-join"`
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"`
}
Persistent struct {
Enabled bool
UnregisteredChannels bool `yaml:"unregistered-channels"`
RegisteredChannels PersistentStatus `yaml:"registered-channels"`
DirectMessages PersistentStatus `yaml:"direct-messages"`
}
}
Filename string
@@ -626,6 +790,11 @@ func LoadConfig(filename string) (config *Config, err error) {
if config.Limits.RegistrationMessages == 0 {
config.Limits.RegistrationMessages = 1024
}
if config.Datastore.MySQL.Enabled {
if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength {
return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength)
}
}
config.Server.supportedCaps = caps.NewCompleteSet()
config.Server.capValues = make(caps.Values)
@@ -636,10 +805,6 @@ func LoadConfig(filename string) (config *Config, err error) {
}
if config.Server.STS.Enabled {
config.Server.STS.Duration, err = custime.ParseDuration(config.Server.STS.DurationString)
if err != nil {
return nil, fmt.Errorf("Could not parse STS duration: %s", err.Error())
}
if config.Server.STS.Port < 0 || config.Server.STS.Port > 65535 {
return nil, fmt.Errorf("STS port is incorrect, should be 0 if disabled: %d", config.Server.STS.Port)
}
@@ -692,8 +857,15 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Server.capValues[caps.Multiline] = multilineCapValue
}
if !config.Accounts.Bouncer.Enabled {
config.Server.supportedCaps.Disable(caps.Bouncer)
// handle legacy name 'bouncer' for 'multiclient' section:
if config.Accounts.Bouncer != nil {
config.Accounts.Multiclient = *config.Accounts.Bouncer
}
if !config.Accounts.Multiclient.Enabled {
config.Accounts.Multiclient.AlwaysOn = PersistentDisabled
} else if config.Accounts.Multiclient.AlwaysOn >= PersistentOptOut {
config.Accounts.Multiclient.AllowedByDefault = true
}
var newLogConfigs []logger.LoggingConfig
@@ -762,6 +934,11 @@ func LoadConfig(filename string) (config *Config, err error) {
return nil, fmt.Errorf("Could not parse proxy-allowed-from nets: %v", err.Error())
}
config.Server.secureNets, err = utils.ParseNetList(config.Server.SecureNetDefs)
if err != nil {
return nil, fmt.Errorf("Could not parse secure-nets: %v\n", err.Error())
}
rawRegexp := config.Accounts.VHosts.ValidRegexpRaw
if rawRegexp != "" {
regexp, err := regexp.Compile(rawRegexp)
@@ -858,6 +1035,18 @@ func LoadConfig(filename string) (config *Config, err error) {
config.History.ClientLength = 0
}
if !config.History.Enabled || !config.History.Persistent.Enabled {
config.History.Persistent.UnregisteredChannels = false
config.History.Persistent.RegisteredChannels = PersistentDisabled
config.History.Persistent.DirectMessages = PersistentDisabled
}
if config.History.ZNCMax == 0 {
config.History.ZNCMax = config.History.ChathistoryMax
}
config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
config.Server.Cloaks.Initialize()
if config.Server.Cloaks.Enabled {
if config.Server.Cloaks.Secret == "" || config.Server.Cloaks.Secret == "siaELnk6Kaeo65K3RCrwJjlWaZ-Bt3WuZ2L8MXLbNb4" {
@@ -942,12 +1131,6 @@ func (config *Config) Diff(oldConfig *Config) (addedCaps, removedCaps *caps.Set)
removedCaps.Add(caps.SASL)
}
if !oldConfig.Accounts.Bouncer.Enabled && config.Accounts.Bouncer.Enabled {
addedCaps.Add(caps.Bouncer)
} else if oldConfig.Accounts.Bouncer.Enabled && !config.Accounts.Bouncer.Enabled {
removedCaps.Add(caps.Bouncer)
}
if oldConfig.Limits.Multiline.MaxBytes != 0 && config.Limits.Multiline.MaxBytes == 0 {
removedCaps.Add(caps.Multiline)
} else if oldConfig.Limits.Multiline.MaxBytes == 0 && config.Limits.Multiline.MaxBytes != 0 {
+17 -1
View File
@@ -75,8 +75,9 @@ var unitMap = map[string]int64{
"m": int64(time.Minute),
"h": int64(time.Hour),
"d": int64(time.Hour * 24),
"w": int64(time.Hour * 24 * 7),
"mo": int64(time.Hour * 24 * 30),
"y": int64(time.Hour * 24 * 265),
"y": int64(time.Hour * 24 * 365),
}
// ParseDuration parses a duration string.
@@ -181,3 +182,18 @@ func ParseDuration(s string) (time.Duration, error) {
}
return time.Duration(d), nil
}
type Duration time.Duration
func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error {
var orig string
var err error
if err = unmarshal(&orig); err != nil {
return err
}
result, err := ParseDuration(orig)
if err == nil {
*d = Duration(result)
}
return err
}
+4 -20
View File
@@ -36,22 +36,6 @@ type SchemaChange struct {
// maps an initial version to a schema change capable of upgrading it
var schemaChanges map[string]SchemaChange
type incompatibleSchemaError struct {
currentVersion string
requiredVersion string
}
func IncompatibleSchemaError(currentVersion string) (result *incompatibleSchemaError) {
return &incompatibleSchemaError{
currentVersion: currentVersion,
requiredVersion: latestDbSchema,
}
}
func (err *incompatibleSchemaError) Error() string {
return fmt.Sprintf("Database requires update. Expected schema v%s, got v%s", err.requiredVersion, err.currentVersion)
}
// InitDB creates the database, implementing the `oragono initdb` command.
func InitDB(path string) {
_, err := os.Stat(path)
@@ -129,7 +113,7 @@ func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB,
// successful autoupgrade, let's try this again:
return openDatabaseInternal(config, false)
} else {
err = IncompatibleSchemaError(version)
err = &utils.IncompatibleSchemaError{CurrentVersion: version, RequiredVersion: latestDbSchema}
return
}
}
@@ -179,7 +163,7 @@ func UpgradeDB(config *Config) (err error) {
break
}
// unable to upgrade to the desired version, roll back
return IncompatibleSchemaError(version)
return &utils.IncompatibleSchemaError{CurrentVersion: version, RequiredVersion: latestDbSchema}
}
log.Println("attempting to update schema from version " + version)
err := change.Changer(config, tx)
@@ -509,14 +493,14 @@ func schemaChangeV6ToV7(config *Config, tx *buntdb.Tx) error {
type accountSettingsLegacyV7 struct {
AutoreplayLines *int
NickEnforcement NickEnforcementMethod
AllowBouncer BouncerAllowedSetting
AllowBouncer MulticlientAllowedSetting
AutoreplayJoins bool
}
type accountSettingsLegacyV8 struct {
AutoreplayLines *int
NickEnforcement NickEnforcementMethod
AllowBouncer BouncerAllowedSetting
AllowBouncer MulticlientAllowedSetting
ReplayJoins ReplayJoinsSetting
}
+2
View File
@@ -33,6 +33,7 @@ var (
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")
errChannelNotRegistered = errors.New("Channel is not registered")
errChannelNameInUse = errors.New(`Channel name in use`)
errInvalidChannelName = errors.New(`Invalid channel name`)
errMonitorLimitExceeded = errors.New("Monitor limit exceeded")
@@ -40,6 +41,7 @@ var (
errNicknameInvalid = errors.New("invalid nickname")
errNicknameInUse = errors.New("nickname in use")
errNicknameReserved = errors.New("nickname is reserved")
errCantChangeNick = errors.New(`Always-on clients can't change nicknames`)
errNoExistingBan = errors.New("Ban does not exist")
errNoSuchChannel = errors.New(`No such channel`)
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
+2 -2
View File
@@ -61,7 +61,7 @@ func (wc *webircConfig) Populate() (err error) {
func (client *Client) ApplyProxiedIP(session *Session, proxiedIP string, tls bool) (err error, quitMsg string) {
// PROXY and WEBIRC are never accepted from a Tor listener, even if the address itself
// is whitelisted:
if client.isTor {
if session.isTor {
return errBadProxyLine, ""
}
@@ -88,7 +88,7 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP string, tls boo
session.proxiedIP = parsedProxiedIP
// nickmask will be updated when the client completes registration
// set tls info
client.certfp = ""
session.certfp = ""
client.SetMode(modes.TLS, tls)
return nil, ""
+83 -17
View File
@@ -65,6 +65,7 @@ type SessionData struct {
atime time.Time
ip net.IP
hostname string
certfp string
}
func (client *Client) AllSessionData(currentSession *Session) (data []SessionData, currentIndex int) {
@@ -81,6 +82,7 @@ func (client *Client) AllSessionData(currentSession *Session) (data []SessionDat
atime: session.atime,
ctime: session.ctime,
hostname: session.rawHostname,
certfp: session.certfp,
}
if session.proxiedIP != nil {
data[i].ip = session.proxiedIP
@@ -91,21 +93,34 @@ func (client *Client) AllSessionData(currentSession *Session) (data []SessionDat
return
}
func (client *Client) AddSession(session *Session) (success bool) {
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSignoff time.Time) {
defer func() {
if !lastSignoff.IsZero() {
client.wakeWriter()
}
}()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
// client may be dying and ineligible to receive another session
if client.destroyed {
return false
return
}
// success, attach the new session to the client
session.client = client
newSessions := make([]*Session, len(client.sessions)+1)
copy(newSessions, client.sessions)
newSessions[len(newSessions)-1] = session
if len(client.sessions) == 0 && client.accountSettings.AutoreplayMissed {
// n.b. this is only possible if client is persistent and remained
// on the server with no sessions:
lastSignoff = client.lastSignoff
client.lastSignoff = time.Time{}
client.dirtyBits |= IncludeLastSignoff
}
client.sessions = newSessions
return true
return true, len(client.sessions), lastSignoff
}
func (client *Client) removeSession(session *Session) (success bool, length int) {
@@ -189,6 +204,13 @@ func (client *Client) SetExitedSnomaskSent() {
client.stateMutex.Unlock()
}
func (client *Client) AlwaysOn() (alwaysOn bool) {
client.stateMutex.Lock()
alwaysOn = client.alwaysOn
client.stateMutex.Unlock()
return
}
// uniqueIdentifiers returns the strings for which the server enforces per-client
// uniqueness/ownership; no two clients can have colliding casefolded nicks or
// skeletons.
@@ -264,23 +286,43 @@ func (client *Client) AccountName() string {
return client.accountName
}
func (client *Client) SetAccountName(account string) (changed bool) {
var casefoldedAccount string
var err error
if account != "" {
if casefoldedAccount, err = CasefoldName(account); err != nil {
return
}
}
func (client *Client) Login(account ClientAccount) {
alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn)
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
changed = client.account != casefoldedAccount
client.account = casefoldedAccount
client.accountName = account
client.account = account.NameCasefolded
client.accountName = account.Name
client.accountSettings = account.Settings
// check `registered` to avoid incorrectly marking a temporary (pre-reattach),
// SASL'ing client as always-on
if client.registered {
client.alwaysOn = alwaysOn
}
client.accountRegDate = account.RegisteredAt
return
}
func (client *Client) historyCutoff() (cutoff time.Time) {
client.stateMutex.Lock()
if client.account != "" {
cutoff = client.accountRegDate
} else {
cutoff = client.ctime
}
client.stateMutex.Unlock()
return
}
func (client *Client) Logout() {
client.stateMutex.Lock()
client.account = ""
client.accountName = ""
client.alwaysOn = false
client.accountRegDate = time.Time{}
client.accountSettings = AccountSettings{}
client.stateMutex.Unlock()
}
func (client *Client) AccountSettings() (result AccountSettings) {
client.stateMutex.RLock()
result = client.accountSettings
@@ -289,8 +331,12 @@ func (client *Client) AccountSettings() (result AccountSettings) {
}
func (client *Client) SetAccountSettings(settings AccountSettings) {
alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, settings.AlwaysOn)
client.stateMutex.Lock()
client.accountSettings = settings
if client.registered {
client.alwaysOn = alwaysOn
}
client.stateMutex.Unlock()
}
@@ -309,11 +355,17 @@ func (client *Client) SetLanguages(languages []string) {
func (client *Client) HasMode(mode modes.Mode) bool {
// client.flags has its own synch
return client.flags.HasMode(mode)
return client.modes.HasMode(mode)
}
func (client *Client) SetMode(mode modes.Mode, on bool) bool {
return client.flags.SetMode(mode, on)
return client.modes.SetMode(mode, on)
}
func (client *Client) SetRealname(realname string) {
client.stateMutex.Lock()
client.realname = realname
client.stateMutex.Unlock()
}
func (client *Client) Channels() (result []*Channel) {
@@ -410,3 +462,17 @@ func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) {
channel.stateMutex.RUnlock()
return clientModes.HighestChannelUserMode()
}
func (channel *Channel) Settings() (result ChannelSettings) {
channel.stateMutex.RLock()
result = channel.settings
channel.stateMutex.RUnlock()
return result
}
func (channel *Channel) SetSettings(settings ChannelSettings) {
channel.stateMutex.Lock()
channel.settings = settings
channel.stateMutex.Unlock()
channel.MarkDirty(IncludeSettings)
}
+208 -286
View File
@@ -112,6 +112,7 @@ func sendSuccessfulAccountAuth(client *Client, rb *ResponseBuffer, forNS, forSAS
// AUTHENTICATE [<mechanism>|<data>|*]
func authenticateHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
session := rb.session
config := server.Config()
details := client.Details()
@@ -128,20 +129,17 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.IrcMessage,
// sasl abort
if !server.AccountConfig().AuthenticationEnabled || len(msg.Params) == 1 && msg.Params[0] == "*" {
rb.Add(nil, server.name, ERR_SASLABORTED, details.nick, client.t("SASL authentication aborted"))
client.saslInProgress = false
client.saslMechanism = ""
client.saslValue = ""
session.sasl.Clear()
return false
}
// start new sasl session
if !client.saslInProgress {
if session.sasl.mechanism == "" {
mechanism := strings.ToUpper(msg.Params[0])
_, mechanismIsEnabled := EnabledSaslMechanisms[mechanism]
if mechanismIsEnabled {
client.saslInProgress = true
client.saslMechanism = mechanism
session.sasl.mechanism = mechanism
if !config.Server.Compatibility.SendUnprefixedSasl {
// normal behavior
rb.Add(nil, server.name, "AUTHENTICATE", "+")
@@ -162,58 +160,46 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.IrcMessage,
if len(rawData) > 400 {
rb.Add(nil, server.name, ERR_SASLTOOLONG, details.nick, client.t("SASL message too long"))
client.saslInProgress = false
client.saslMechanism = ""
client.saslValue = ""
session.sasl.Clear()
return false
} else if len(rawData) == 400 {
client.saslValue += rawData
// allow 4 'continuation' lines before rejecting for length
if len(client.saslValue) > 400*4 {
if len(session.sasl.value) >= 400*4 {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long"))
client.saslInProgress = false
client.saslMechanism = ""
client.saslValue = ""
session.sasl.Clear()
return false
}
session.sasl.value += rawData
return false
}
if rawData != "+" {
client.saslValue += rawData
session.sasl.value += rawData
}
var data []byte
var err error
if client.saslValue != "+" {
data, err = base64.StdEncoding.DecodeString(client.saslValue)
if session.sasl.value != "+" {
data, err = base64.StdEncoding.DecodeString(session.sasl.value)
if err != nil {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding"))
client.saslInProgress = false
client.saslMechanism = ""
client.saslValue = ""
session.sasl.Clear()
return false
}
}
// call actual handler
handler, handlerExists := EnabledSaslMechanisms[client.saslMechanism]
handler, handlerExists := EnabledSaslMechanisms[session.sasl.mechanism]
// like 100% not required, but it's good to be safe I guess
if !handlerExists {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed"))
client.saslInProgress = false
client.saslMechanism = ""
client.saslValue = ""
session.sasl.Clear()
return false
}
// let the SASL handler do its thing
exiting := handler(server, client, client.saslMechanism, data, rb)
// wait 'til SASL is done before emptying the sasl vars
client.saslInProgress = false
client.saslMechanism = ""
client.saslValue = ""
exiting := handler(server, client, session.sasl.mechanism, data, rb)
session.sasl.Clear()
return exiting
}
@@ -270,7 +256,7 @@ func authErrorToMessage(server *Server, err error) (msg string) {
// AUTHENTICATE EXTERNAL
func authExternalHandler(server *Server, client *Client, mechanism string, value []byte, rb *ResponseBuffer) bool {
if client.certfp == "" {
if rb.session.certfp == "" {
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed, you are not connecting with a certificate"))
return false
}
@@ -287,7 +273,7 @@ func authExternalHandler(server *Server, client *Client, mechanism string, value
}
if err == nil {
err = server.accounts.AuthenticateByCertFP(client, authzid)
err = server.accounts.AuthenticateByCertFP(client, rb.session.certfp, authzid)
}
if err != nil {
@@ -531,64 +517,49 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
// CHATHISTORY <target> BETWEEN <query> <query> <direction> [<limit>]
// e.g., CHATHISTORY #ircv3 BETWEEN timestamp=YYYY-MM-DDThh:mm:ss.sssZ timestamp=YYYY-MM-DDThh:mm:ss.sssZ + 100
func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (exiting bool) {
config := server.Config()
var items []history.Item
success := false
var hist *history.Buffer
unknown_command := false
var target string
var channel *Channel
var sequence history.Sequence
var err error
defer func() {
// successful responses are sent as a chathistory or history batch
if success && 0 < len(items) {
if channel == nil {
client.replayPrivmsgHistory(rb, items, true)
} else {
if err == nil {
if channel != nil {
channel.replayHistoryItems(rb, items, false)
} else {
client.replayPrivmsgHistory(rb, items, target, true)
}
return
}
// errors are sent either without a batch, or in a draft/labeled-response batch as usual
// TODO: send `WARN CHATHISTORY MAX_MESSAGES_EXCEEDED` when appropriate
if hist == nil {
rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_SUCH_CHANNEL")
} else if len(items) == 0 {
rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_TEXT_TO_SEND")
} else if !success {
rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NEED_MORE_PARAMS")
if unknown_command {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "UNKNOWN_COMMAND", utils.SafeErrorParam(msg.Params[0]), client.t("Unknown command"))
} else if err == utils.ErrInvalidParams {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_PARAMETERS", msg.Params[0], client.t("Invalid parameters"))
} else if err != nil {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "MESSAGE_ERROR", msg.Params[0], client.t("Messages could not be retrieved"))
} else if sequence == nil {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "NO_SUCH_CHANNEL", utils.SafeErrorParam(msg.Params[1]), client.t("No such channel"))
}
}()
target := msg.Params[0]
channel = server.channels.Get(target)
if channel != nil && channel.hasClient(client) {
// "If [...] the user does not have permission to view the requested content, [...]
// NO_SUCH_CHANNEL SHOULD be returned"
hist = &channel.history
} else {
targetClient := server.clients.Get(target)
if targetClient != nil {
myAccount := client.Account()
targetAccount := targetClient.Account()
if myAccount != "" && targetAccount != "" && myAccount == targetAccount {
hist = &targetClient.history
}
}
}
if hist == nil {
config := server.Config()
maxChathistoryLimit := config.History.ChathistoryMax
if maxChathistoryLimit == 0 {
return
}
preposition := strings.ToLower(msg.Params[1])
parseQueryParam := func(param string) (msgid string, timestamp time.Time, err error) {
err = errInvalidParams
err = utils.ErrInvalidParams
pieces := strings.SplitN(param, "=", 2)
if len(pieces) < 2 {
return
}
identifier, value := strings.ToLower(pieces[0]), pieces[1]
if identifier == "id" {
if identifier == "msgid" {
msgid, err = value, nil
return
} else if identifier == "timestamp" {
@@ -598,10 +569,6 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
return
}
maxChathistoryLimit := config.History.ChathistoryMax
if maxChathistoryLimit == 0 {
return
}
parseHistoryLimit := func(paramIndex int) (limit int) {
if len(msg.Params) < (paramIndex + 1) {
return maxChathistoryLimit
@@ -613,140 +580,74 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
return
}
// TODO: as currently implemented, almost all of thes queries are worst-case O(n)
// in the number of stored history entries. Every one of them can be made O(1)
// if necessary, without too much difficulty. Some ideas:
// * Ensure that the ring buffer is sorted by time, enabling binary search for times
// * Maintain a map from msgid to position in the ring buffer
if preposition == "between" {
if len(msg.Params) >= 5 {
startMsgid, startTimestamp, startErr := parseQueryParam(msg.Params[2])
endMsgid, endTimestamp, endErr := parseQueryParam(msg.Params[3])
ascending := msg.Params[4] == "+"
limit := parseHistoryLimit(5)
if startErr != nil || endErr != nil {
success = false
} else if startMsgid != "" && endMsgid != "" {
inInterval := false
matches := func(item history.Item) (result bool) {
result = inInterval
if item.HasMsgid(startMsgid) {
if ascending {
inInterval = true
} else {
inInterval = false
return false // interval is exclusive
}
} else if item.HasMsgid(endMsgid) {
if ascending {
inInterval = false
return false
} else {
inInterval = true
}
}
return
}
items = hist.Match(matches, ascending, limit)
success = true
} else if !startTimestamp.IsZero() && !endTimestamp.IsZero() {
items, _ = hist.Between(startTimestamp, endTimestamp, ascending, limit)
if !ascending {
history.Reverse(items)
}
success = true
}
// else: mismatched params, success = false, fail
}
preposition := strings.ToLower(msg.Params[0])
target = msg.Params[1]
channel, sequence, err = server.GetHistorySequence(nil, client, target)
if err != nil || sequence == nil {
return
}
// before, after, latest, around
queryParam := msg.Params[2]
msgid, timestamp, err := parseQueryParam(queryParam)
limit := parseHistoryLimit(3)
before := false
switch preposition {
case "before":
before = true
fallthrough
case "after":
var matches history.Predicate
if err != nil {
break
} else if msgid != "" {
inInterval := false
matches = func(item history.Item) (result bool) {
result = inInterval
if item.HasMsgid(msgid) {
inInterval = true
}
return
}
} else {
matches = func(item history.Item) bool {
return before == item.Message.Time.Before(timestamp)
}
}
items = hist.Match(matches, !before, limit)
success = true
case "latest":
if queryParam == "*" {
items = hist.Latest(limit)
} else if err != nil {
break
} else {
var matches history.Predicate
if msgid != "" {
shouldStop := false
matches = func(item history.Item) bool {
if shouldStop {
return false
}
shouldStop = item.HasMsgid(msgid)
return !shouldStop
}
} else {
matches = func(item history.Item) bool {
return item.Message.Time.After(timestamp)
}
}
items = hist.Match(matches, false, limit)
}
success = true
case "around":
if err != nil {
break
}
var initialMatcher history.Predicate
if msgid != "" {
inInterval := false
initialMatcher = func(item history.Item) (result bool) {
if inInterval {
return true
} else {
inInterval = item.HasMsgid(msgid)
return inInterval
}
}
} else {
initialMatcher = func(item history.Item) (result bool) {
return item.Message.Time.Before(timestamp)
}
}
var halfLimit int
halfLimit = (limit + 1) / 2
firstPass := hist.Match(initialMatcher, false, halfLimit)
if len(firstPass) > 0 {
timeWindowStart := firstPass[0].Message.Time
items = hist.Match(func(item history.Item) bool {
return item.Message.Time.Equal(timeWindowStart) || item.Message.Time.After(timeWindowStart)
}, true, limit)
}
success = true
roundUp := func(endpoint time.Time) (result time.Time) {
return endpoint.Truncate(time.Millisecond).Add(time.Millisecond)
}
var start, end history.Selector
var limit int
switch preposition {
case "between":
start.Msgid, start.Time, err = parseQueryParam(msg.Params[2])
if err != nil {
return
}
end.Msgid, end.Time, err = parseQueryParam(msg.Params[3])
if err != nil {
return
}
// XXX preserve the ordering of the two parameters, since we might be going backwards,
// but round up the chronologically first one, whichever it is, to make it exclusive
if !start.Time.IsZero() && !end.Time.IsZero() {
if start.Time.Before(end.Time) {
start.Time = roundUp(start.Time)
} else {
end.Time = roundUp(end.Time)
}
}
limit = parseHistoryLimit(4)
case "before", "after", "around":
start.Msgid, start.Time, err = parseQueryParam(msg.Params[2])
if err != nil {
return
}
if preposition == "after" && !start.Time.IsZero() {
start.Time = roundUp(start.Time)
}
if preposition == "before" {
end = start
start = history.Selector{}
}
limit = parseHistoryLimit(3)
case "latest":
if msg.Params[2] != "*" {
end.Msgid, end.Time, err = parseQueryParam(msg.Params[2])
if err != nil {
return
}
if !end.Time.IsZero() {
end.Time = roundUp(end.Time)
}
start.Time = time.Now().UTC()
}
limit = parseHistoryLimit(3)
default:
unknown_command = true
return
}
if preposition == "around" {
items, err = sequence.Around(start, limit)
} else {
items, _, err = sequence.Between(start, end, limit)
}
return
}
@@ -1026,6 +927,7 @@ Get an explanation of <argument>, or "index" for a list of help topics.`), rb)
// HISTORY <target> [<limit>]
// e.g., HISTORY #ubuntu 10
// HISTORY me 15
// HISTORY #darwin 1h
func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
config := server.Config()
if !config.History.Enabled {
@@ -1034,53 +936,55 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
}
target := msg.Params[0]
var hist *history.Buffer
channel := server.channels.Get(target)
if channel != nil && channel.hasClient(client) {
hist = &channel.history
} else {
if strings.ToLower(target) == "me" {
hist = &client.history
} else {
targetClient := server.clients.Get(target)
if targetClient != nil {
myAccount, targetAccount := client.Account(), targetClient.Account()
if myAccount != "" && targetAccount != "" && myAccount == targetAccount {
hist = &targetClient.history
}
if strings.ToLower(target) == "me" {
target = "*"
}
channel, sequence, err := server.GetHistorySequence(nil, client, target)
if sequence == nil || err != nil {
// whatever
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
return false
}
var duration time.Duration
maxChathistoryLimit := config.History.ChathistoryMax
limit := 100
if maxChathistoryLimit < limit {
limit = maxChathistoryLimit
}
if len(msg.Params) > 1 {
providedLimit, err := strconv.Atoi(msg.Params[1])
if err == nil && providedLimit != 0 {
limit = providedLimit
if maxChathistoryLimit < limit {
limit = maxChathistoryLimit
}
} else if err != nil {
duration, err = time.ParseDuration(msg.Params[1])
if err == nil {
limit = maxChathistoryLimit
}
}
}
if hist == nil {
if channel == nil {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
} else {
rb.Add(nil, server.name, ERR_NOTONCHANNEL, client.Nick(), target, client.t("You're not on that channel"))
}
return false
}
limit := 10
maxChathistoryLimit := config.History.ChathistoryMax
if len(msg.Params) > 1 {
providedLimit, err := strconv.Atoi(msg.Params[1])
if providedLimit > maxChathistoryLimit {
providedLimit = maxChathistoryLimit
}
if err == nil && providedLimit != 0 {
limit = providedLimit
}
}
items := hist.Latest(limit)
if channel != nil {
channel.replayHistoryItems(rb, items, false)
var items []history.Item
if duration == 0 {
items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
} else {
client.replayPrivmsgHistory(rb, items, true)
now := time.Now().UTC()
start := history.Selector{Time: now}
end := history.Selector{Time: now.Add(-duration)}
items, _, err = sequence.Between(start, end, limit)
}
if err == nil && len(items) != 0 {
if channel != nil {
channel.replayHistoryItems(rb, items, false)
} else {
client.replayPrivmsgHistory(rb, items, "", true)
}
}
return false
}
@@ -1964,7 +1868,7 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
return false
}
if client.isTor && utils.IsRestrictedCTCPMessage(message) {
if rb.session.isTor && utils.IsRestrictedCTCPMessage(message) {
// note that error replies are never sent for NOTICE
if histType != history.Notice {
rb.Notice(client.t("CTCP messages are disabled over Tor"))
@@ -2021,46 +1925,34 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
}
return
}
tnick := user.Nick()
tDetails := user.Details()
tnick := tDetails.nick
nickMaskString := client.NickMaskString()
accountName := client.AccountName()
details := client.Details()
nickMaskString := details.nickMask
accountName := details.accountName
var deliverySessions []*Session
// restrict messages appropriately when +R is set
// intentionally make the sending user think the message went through fine
allowedPlusR := !user.HasMode(modes.RegisteredOnly) || client.LoggedIntoAccount()
allowedTor := !user.isTor || !message.IsRestrictedCTCPMessage()
if allowedPlusR && allowedTor {
for _, session := range user.Sessions() {
hasTagsCap := session.capabilities.Has(caps.MessageTags)
// don't send TAGMSG at all if they don't have the tags cap
if histType == history.Tagmsg && hasTagsCap {
session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
} else if histType != history.Tagmsg {
tagsToSend := tags
if !hasTagsCap {
tagsToSend = nil
}
session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, tagsToSend, command, tnick, message)
allowedPlusR := details.account != "" || !user.HasMode(modes.RegisteredOnly)
if allowedPlusR {
deliverySessions = append(deliverySessions, user.Sessions()...)
}
// all sessions of the sender, except the originating session, get a copy as well:
if client != user {
for _, session := range client.Sessions() {
if session != rb.session {
deliverySessions = append(deliverySessions, session)
}
}
}
// an echo-message may need to be included in the response:
if rb.session.capabilities.Has(caps.EchoMessage) {
if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
rb.AddFromClient(message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
} else {
rb.AddSplitMessageFromClient(nickMaskString, accountName, tags, command, tnick, message)
}
}
// an echo-message may need to go out to other client sessions:
for _, session := range client.Sessions() {
if session == rb.session {
continue
}
for _, session := range deliverySessions {
hasTagsCap := session.capabilities.Has(caps.MessageTags)
// don't send TAGMSG at all if they don't have the tags cap
if histType == history.Tagmsg && hasTagsCap {
session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
} else if histType != history.Tagmsg {
} else if histType != history.Tagmsg && !(session.isTor && message.IsRestrictedCTCPMessage()) {
tagsToSend := tags
if !hasTagsCap {
tagsToSend = nil
@@ -2068,22 +1960,56 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, tagsToSend, command, tnick, message)
}
}
// the originating session may get an echo message:
if rb.session.capabilities.Has(caps.EchoMessage) {
hasTagsCap := rb.session.capabilities.Has(caps.MessageTags)
if histType == history.Tagmsg && hasTagsCap {
rb.AddFromClient(message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick)
} else {
tagsToSend := tags
if !hasTagsCap {
tagsToSend = nil
}
rb.AddSplitMessageFromClient(nickMaskString, accountName, tagsToSend, command, tnick, message)
}
}
if histType != history.Notice && user.Away() {
//TODO(dan): possibly implement cooldown of away notifications to users
rb.Add(nil, server.name, RPL_AWAY, client.Nick(), tnick, user.AwayMessage())
}
config := server.Config()
if !config.History.Enabled {
return
}
item := history.Item{
Type: histType,
Message: message,
Nick: nickMaskString,
AccountName: accountName,
Tags: tags,
}
if !item.IsStorable() {
return
}
targetedItem := item
targetedItem.Params[0] = tnick
cPersistent, cEphemeral, _ := client.historyStatus(config)
tPersistent, tEphemeral, _ := user.historyStatus(config)
// add to ephemeral history
if cEphemeral {
targetedItem.CfCorrespondent = tDetails.nickCasefolded
client.history.Add(targetedItem)
}
if tEphemeral && client != user {
item.CfCorrespondent = details.nickCasefolded
user.history.Add(item)
}
if cPersistent || tPersistent {
item.CfCorrespondent = ""
server.historyDB.AddDirectMessage(details.nickCasefolded, user.NickCasefolded(), cPersistent, tPersistent, targetedItem)
}
// add to the target's history:
user.history.Add(item)
// add this to the client's history as well, recording the target:
item.Params[0] = tnick
client.history.Add(item)
}
}
@@ -2136,7 +2062,7 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
oper := server.GetOperator(msg.Params[0])
if oper != nil {
if oper.Fingerprint != "" {
if oper.Fingerprint == client.certfp {
if oper.Fingerprint == rb.session.certfp {
checkPassed = true
} else {
checkFailed = true
@@ -2239,7 +2165,7 @@ func passHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
// check the provided password
password := []byte(msg.Params[0])
client.sentPassCommand = bcrypt.CompareHashAndPassword(serverPassword, password) == nil
rb.session.sentPassCommand = bcrypt.CompareHashAndPassword(serverPassword, password) == nil
// if they failed the check, we'll bounce them later when they try to complete registration
return false
@@ -2409,11 +2335,7 @@ func sceneHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
// SETNAME <realname>
func setnameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
realname := msg.Params[0]
client.stateMutex.Lock()
client.realname = realname
client.stateMutex.Unlock()
client.SetRealname(realname)
details := client.Details()
// alert friends
@@ -2622,7 +2544,7 @@ func webircHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil {
continue
}
if info.Fingerprint != "" && info.Fingerprint != client.certfp {
if info.Fingerprint != "" && info.Fingerprint != rb.session.certfp {
continue
}
+6 -5
View File
@@ -145,9 +145,9 @@ http://ircv3.net/specs/core/capability-negotiation-3.2.html`,
"chathistory": {
text: `CHATHISTORY [params]
CHATHISTORY is an experimental history replay command. See these documents:
https://github.com/MuffinMedic/ircv3-specifications/blob/chathistory/extensions/chathistory.md
https://gist.github.com/DanielOaks/c104ad6e8759c01eb5c826d627caf80d`,
CHATHISTORY is a history replay command associated with the IRCv3
specification draft/chathistory. See this document:
https://github.com/ircv3/ircv3-specifications/pull/393`,
},
"debug": {
oper: true,
@@ -213,8 +213,9 @@ Get an explanation of <argument>, or "index" for a list of help topics.`,
Replay message history. <target> can be a channel name, "me" to replay direct
message history, or a nickname to replay another client's direct message
history (they must be logged into the same account as you). At most [limit]
messages will be replayed.`,
history (they must be logged into the same account as you). [limit] can be
either an integer (the maximum number of messages to replay), or a time
duration like 10m or 1h (the time window within which to replay messages).`,
},
"info": {
text: `INFO
+97 -68
View File
@@ -6,7 +6,6 @@ package history
import (
"github.com/oragono/oragono/irc/utils"
"sync"
"sync/atomic"
"time"
)
@@ -46,6 +45,10 @@ type Item struct {
Message utils.SplitMessage
Tags map[string]string
Params [1]string
// for a DM, this is the casefolded nickname of the other party (whether this is
// an incoming or outgoing message). this lets us emulate the "query buffer" functionality
// required by CHATHISTORY:
CfCorrespondent string
}
// HasMsgid tests whether a message has the message id `msgid`.
@@ -53,20 +56,30 @@ func (item *Item) HasMsgid(msgid string) bool {
return item.Message.Msgid == msgid
}
func (item *Item) isStorable() bool {
if item.Type == Tagmsg {
func (item *Item) IsStorable() bool {
switch item.Type {
case Tagmsg:
for name := range item.Tags {
if !transientTags[name] {
return true
}
}
return false // all tags were blacklisted
} else {
case Privmsg, Notice:
// don't store CTCP other than ACTION
return !item.Message.IsRestrictedCTCPMessage()
default:
return true
}
}
type Predicate func(item Item) (matches bool)
type Predicate func(item *Item) (matches bool)
func Reverse(results []Item) {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
// Buffer is a ring buffer holding message/event history for a channel or user
type Buffer struct {
@@ -81,8 +94,6 @@ type Buffer struct {
lastDiscarded time.Time
enabled uint32
nowFunc func() time.Time
}
@@ -99,8 +110,6 @@ func (hist *Buffer) Initialize(size int, window time.Duration) {
hist.window = window
hist.maximumSize = size
hist.nowFunc = time.Now
hist.setEnabled(size)
}
// compute the initial size for the buffer, taking into account autoresize
@@ -115,31 +124,8 @@ func (hist *Buffer) initialSize(size int, window time.Duration) (result int) {
return
}
func (hist *Buffer) setEnabled(size int) {
var enabled uint32
if size != 0 {
enabled = 1
}
atomic.StoreUint32(&hist.enabled, enabled)
}
// Enabled returns whether the buffer is currently storing messages
// (a disabled buffer blackholes everything it sees)
func (list *Buffer) Enabled() bool {
return atomic.LoadUint32(&list.enabled) != 0
}
// Add adds a history item to the buffer
func (list *Buffer) Add(item Item) {
// fast path without a lock acquisition for when we are not storing history
if !list.Enabled() {
return
}
if !item.isStorable() {
return
}
if item.Message.Time.IsZero() {
item.Message.Time = time.Now().UTC()
}
@@ -147,6 +133,10 @@ func (list *Buffer) Add(item Item) {
list.Lock()
defer list.Unlock()
if len(list.buffer) == 0 {
return
}
list.maybeExpand()
var pos int
@@ -170,55 +160,100 @@ func (list *Buffer) Add(item Item) {
list.buffer[pos] = item
}
// Reverse reverses an []Item, in-place.
func Reverse(results []Item) {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
func (list *Buffer) lookup(msgid string) (result Item, found bool) {
predicate := func(item *Item) bool {
return item.HasMsgid(msgid)
}
results := list.matchInternal(predicate, false, 1)
if len(results) != 0 {
return results[0], true
}
return
}
// Between returns all history items with a time `after` <= time <= `before`,
// with an indication of whether the results are complete or are missing items
// because some of that period was discarded. A zero value of `before` is considered
// higher than all other times.
func (list *Buffer) Between(after, before time.Time, ascending bool, limit int) (results []Item, complete bool) {
if !list.Enabled() {
return
}
func (list *Buffer) betweenHelper(start, end Selector, cutoff time.Time, pred Predicate, limit int) (results []Item, complete bool, err error) {
var ascending bool
defer func() {
if !ascending {
Reverse(results)
}
}()
list.RLock()
defer list.RUnlock()
if len(list.buffer) == 0 {
return
}
after := start.Time
if start.Msgid != "" {
item, found := list.lookup(start.Msgid)
if !found {
return
}
after = item.Message.Time
}
before := end.Time
if end.Msgid != "" {
item, found := list.lookup(end.Msgid)
if !found {
return
}
before = item.Message.Time
}
after, before, ascending = MinMaxAsc(after, before, cutoff)
complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded)
satisfies := func(item Item) bool {
return (after.IsZero() || item.Message.Time.After(after)) && (before.IsZero() || item.Message.Time.Before(before))
satisfies := func(item *Item) bool {
return (after.IsZero() || item.Message.Time.After(after)) &&
(before.IsZero() || item.Message.Time.Before(before)) &&
(pred == nil || pred(item))
}
return list.matchInternal(satisfies, ascending, limit), complete
return list.matchInternal(satisfies, ascending, limit), complete, nil
}
// Match returns all history items such that `predicate` returns true for them.
// Items are considered in reverse insertion order if `ascending` is false, or
// in insertion order if `ascending` is true, up to a total of `limit` matches
// if `limit` > 0 (unlimited otherwise).
// `predicate` MAY be a closure that maintains its own state across invocations;
// it MUST NOT acquire any locks or otherwise do anything weird.
// Results are always returned in insertion order.
func (list *Buffer) Match(predicate Predicate, ascending bool, limit int) (results []Item) {
if !list.Enabled() {
return
// implements history.Sequence, emulating a single history buffer (for a channel,
// a single user's DMs, or a DM conversation)
type bufferSequence struct {
list *Buffer
pred Predicate
cutoff time.Time
}
func (list *Buffer) MakeSequence(correspondent string, cutoff time.Time) Sequence {
var pred Predicate
if correspondent != "" {
pred = func(item *Item) bool {
return item.CfCorrespondent == correspondent
}
}
return &bufferSequence{
list: list,
pred: pred,
cutoff: cutoff,
}
}
list.RLock()
defer list.RUnlock()
func (seq *bufferSequence) Between(start, end Selector, limit int) (results []Item, complete bool, err error) {
return seq.list.betweenHelper(start, end, seq.cutoff, seq.pred, limit)
}
return list.matchInternal(predicate, ascending, limit)
func (seq *bufferSequence) Around(start Selector, limit int) (results []Item, err error) {
return GenericAround(seq, start, limit)
}
// you must be holding the read lock to call this
func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int) (results []Item) {
if list.start == -1 {
if list.start == -1 || len(list.buffer) == 0 {
return
}
@@ -232,7 +267,7 @@ func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int
}
for {
if predicate(list.buffer[pos]) {
if predicate(&list.buffer[pos]) {
results = append(results, list.buffer[pos])
}
if pos == stop || (limit != 0 && len(results) == limit) {
@@ -245,18 +280,14 @@ func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int
}
}
// TODO sort by time instead?
if !ascending {
Reverse(results)
}
return
}
// Latest returns the items most recently added, up to `limit`. If `limit` is 0,
// latest returns the items most recently added, up to `limit`. If `limit` is 0,
// it returns all items.
func (list *Buffer) Latest(limit int) (results []Item) {
matchAll := func(item Item) bool { return true }
return list.Match(matchAll, false, limit)
func (list *Buffer) latest(limit int) (results []Item) {
results, _, _ = list.betweenHelper(Selector{}, Selector{}, time.Time{}, nil, limit)
return
}
// LastDiscarded returns the latest time of any entry that was evicted
@@ -355,8 +386,6 @@ func (list *Buffer) Resize(maximumSize int, window time.Duration) {
func (list *Buffer) resize(size int) {
newbuffer := make([]Item, size)
list.setEnabled(size)
if list.start == -1 {
// indices are already correct and nothing needs to be copied
} else if size == 0 {
+46 -25
View File
@@ -14,19 +14,21 @@ const (
timeFormat = "2006-01-02 15:04:05Z"
)
func betweenTimestamps(buf *Buffer, start, end time.Time, limit int) (result []Item, complete bool) {
result, complete, _ = buf.betweenHelper(Selector{Time: start}, Selector{Time: end}, time.Time{}, nil, limit)
return
}
func TestEmptyBuffer(t *testing.T) {
pastTime := easyParse(timeFormat)
buf := NewHistoryBuffer(0, 0)
if buf.Enabled() {
t.Error("the buffer of size 0 must be considered disabled")
}
buf.Add(Item{
Nick: "testnick",
})
since, complete := buf.Between(pastTime, time.Now(), false, 0)
since, complete := betweenTimestamps(buf, pastTime, time.Now(), 0)
if len(since) != 0 {
t.Error("shouldn't be able to add to disabled buf")
}
@@ -35,16 +37,13 @@ func TestEmptyBuffer(t *testing.T) {
}
buf.Resize(1, 0)
if !buf.Enabled() {
t.Error("the buffer of size 1 must be considered enabled")
}
since, complete = buf.Between(pastTime, time.Now(), false, 0)
since, complete = betweenTimestamps(buf, pastTime, time.Now(), 0)
assertEqual(complete, true, t)
assertEqual(len(since), 0, t)
buf.Add(Item{
Nick: "testnick",
})
since, complete = buf.Between(pastTime, time.Now(), false, 0)
since, complete = betweenTimestamps(buf, pastTime, time.Now(), 0)
if len(since) != 1 {
t.Error("should be able to store items in a nonempty buffer")
}
@@ -58,7 +57,7 @@ func TestEmptyBuffer(t *testing.T) {
buf.Add(Item{
Nick: "testnick2",
})
since, complete = buf.Between(pastTime, time.Now(), false, 0)
since, complete = betweenTimestamps(buf, pastTime, time.Now(), 0)
if len(since) != 1 {
t.Error("expect exactly 1 item")
}
@@ -68,8 +67,7 @@ func TestEmptyBuffer(t *testing.T) {
if since[0].Nick != "testnick2" {
t.Error("retrieved junk data")
}
matchAll := func(item Item) bool { return true }
assertEqual(toNicks(buf.Match(matchAll, false, 0)), []string{"testnick2"}, t)
assertEqual(toNicks(buf.latest(0)), []string{"testnick2"}, t)
}
func toNicks(items []Item) (result []string) {
@@ -110,27 +108,27 @@ func TestBuffer(t *testing.T) {
buf.Add(easyItem("testnick2", "2006-01-03 15:04:05Z"))
since, complete := buf.Between(start, time.Now(), false, 0)
since, complete := betweenTimestamps(buf, start, time.Now(), 0)
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
// add another item, evicting the first
buf.Add(easyItem("testnick3", "2006-01-04 15:04:05Z"))
since, complete = buf.Between(start, time.Now(), false, 0)
since, complete = betweenTimestamps(buf, start, time.Now(), 0)
assertEqual(complete, false, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
// now exclude the time of the discarded entry; results should be complete again
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now(), false, 0)
since, complete = betweenTimestamps(buf, easyParse("2006-01-02 00:00:00Z"), time.Now(), 0)
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), easyParse("2006-01-03 00:00:00Z"), false, 0)
since, complete = betweenTimestamps(buf, easyParse("2006-01-02 00:00:00Z"), easyParse("2006-01-03 00:00:00Z"), 0)
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick1"}, t)
// shrink the buffer, cutting off testnick1
buf.Resize(2, 0)
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now(), false, 0)
since, complete = betweenTimestamps(buf, easyParse("2006-01-02 00:00:00Z"), time.Now(), 0)
assertEqual(complete, false, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
@@ -138,18 +136,19 @@ func TestBuffer(t *testing.T) {
buf.Add(easyItem("testnick4", "2006-01-05 15:04:05Z"))
buf.Add(easyItem("testnick5", "2006-01-06 15:04:05Z"))
buf.Add(easyItem("testnick6", "2006-01-07 15:04:05Z"))
since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), false, 0)
since, complete = betweenTimestamps(buf, easyParse("2006-01-03 00:00:00Z"), time.Now(), 0)
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t)
// test ascending order
since, _ = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), true, 2)
since, _ = betweenTimestamps(buf, easyParse("2006-01-03 00:00:00Z"), time.Time{}, 2)
assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
}
func autoItem(id int, t time.Time) (result Item) {
result.Message.Time = t
result.Nick = strconv.Itoa(id)
result.Message.Msgid = result.Nick
return
}
@@ -181,7 +180,7 @@ func TestAutoresize(t *testing.T) {
now = now.Add(time.Minute * 10)
id += 1
}
items := buf.Latest(0)
items := buf.latest(0)
assertEqual(len(items), initialAutoSize, t)
assertEqual(atoi(items[0].Nick), 40, t)
assertEqual(atoi(items[len(items)-1].Nick), 71, t)
@@ -195,7 +194,7 @@ func TestAutoresize(t *testing.T) {
// ok, 5 items from the first batch are still in the 1-hour window;
// we should overwrite until only those 5 are left, then start expanding
// the buffer so that it retains those 5 and the 100 new items
items = buf.Latest(0)
items = buf.latest(0)
assertEqual(len(items), 105, t)
assertEqual(atoi(items[0].Nick), 67, t)
assertEqual(atoi(items[len(items)-1].Nick), 171, t)
@@ -207,7 +206,7 @@ func TestAutoresize(t *testing.T) {
id += 1
}
// should fill up to the maximum size of 128 and start overwriting
items = buf.Latest(0)
items = buf.latest(0)
assertEqual(len(items), 128, t)
assertEqual(atoi(items[0].Nick), 144, t)
assertEqual(atoi(items[len(items)-1].Nick), 271, t)
@@ -222,7 +221,7 @@ func TestEnabledByResize(t *testing.T) {
buf.Resize(128, time.Hour)
// add an item and test that it is stored and retrievable
buf.Add(autoItem(0, now))
items := buf.Latest(0)
items := buf.latest(0)
assertEqual(len(items), 1, t)
assertEqual(atoi(items[0].Nick), 0, t)
}
@@ -232,13 +231,13 @@ func TestDisabledByResize(t *testing.T) {
// enabled autoresizing buffer
buf := NewHistoryBuffer(128, time.Hour)
buf.Add(autoItem(0, now))
items := buf.Latest(0)
items := buf.latest(0)
assertEqual(len(items), 1, t)
assertEqual(atoi(items[0].Nick), 0, t)
// disable as during a rehash, confirm that nothing can be retrieved
buf.Resize(0, time.Hour)
items = buf.Latest(0)
items = buf.latest(0)
assertEqual(len(items), 0, t)
}
@@ -252,3 +251,25 @@ func TestRoundUp(t *testing.T) {
assertEqual(roundUpToPowerOfTwo(1025), 2048, t)
assertEqual(roundUpToPowerOfTwo(269435457), 536870912, t)
}
func BenchmarkInsert(b *testing.B) {
buf := NewHistoryBuffer(1024, 0)
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf.Add(Item{})
}
}
func BenchmarkMatch(b *testing.B) {
buf := NewHistoryBuffer(1024, 0)
var now time.Time
for i := 0; i < 1024; i += 1 {
buf.Add(autoItem(i, now))
now = now.Add(time.Second)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf.lookup("512")
}
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright (c) 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package history
import (
"time"
)
// Selector represents a parameter to a CHATHISTORY command;
// at most one of Msgid or Time may be nonzero
type Selector struct {
Msgid string
Time time.Time
}
// Sequence is an abstract sequence of history entries that can be queried;
// it encapsulates restrictions such as registration time cutoffs, or
// only looking at a single "query buffer" (DMs with a particular correspondent)
type Sequence interface {
Between(start, end Selector, limit int) (results []Item, complete bool, err error)
Around(start Selector, limit int) (results []Item, err error)
}
// This is a bad, slow implementation of CHATHISTORY AROUND using the BETWEEN semantics
func GenericAround(seq Sequence, start Selector, limit int) (results []Item, err error) {
var halfLimit int
halfLimit = (limit + 1) / 2
initialResults, _, err := seq.Between(Selector{}, start, halfLimit)
if err != nil {
return
} else if len(initialResults) == 0 {
// TODO: this fails if we're doing an AROUND on the first message in the buffer
// would be nice to fix this but whatever
return
}
newStart := Selector{Time: initialResults[0].Message.Time}
results, _, err = seq.Between(newStart, Selector{}, limit)
return
}
// MinMaxAsc converts CHATHISTORY arguments into time intervals, handling the most
// general case (BETWEEN going forwards or backwards) natively and the other ordering
// queries (AFTER, BEFORE, LATEST) as special cases.
func MinMaxAsc(after, before, cutoff time.Time) (min, max time.Time, ascending bool) {
startIsZero, endIsZero := after.IsZero(), before.IsZero()
if !startIsZero && endIsZero {
// AFTER
ascending = true
} else if startIsZero && !endIsZero {
// BEFORE
ascending = false
} else if !startIsZero && !endIsZero {
if before.Before(after) {
// BETWEEN going backwards
before, after = after, before
ascending = false
} else {
// BETWEEN going forwards
ascending = true
}
} else if startIsZero && endIsZero {
// LATEST
ascending = false
}
if after.IsZero() || after.Before(cutoff) {
// this may result in an impossible query, which is fine
after = cutoff
}
return after, before, ascending
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"regexp"
"time"
"github.com/oragono/oragono/irc/sno"
)
@@ -214,7 +215,7 @@ func hsRequestHandler(server *Server, client *Client, command string, params []s
}
accountName := client.Account()
_, err := server.accounts.VHostRequest(accountName, vhost, server.Config().Accounts.VHosts.UserRequests.Cooldown)
_, err := server.accounts.VHostRequest(accountName, vhost, time.Duration(server.Config().Accounts.VHosts.UserRequests.Cooldown))
if err != nil {
if throttled, ok := err.(*vhostThrottleExceeded); ok {
hsNotice(rb, fmt.Sprintf(client.t("You must wait an additional %v before making another request"), throttled.timeRemaining))
@@ -411,7 +412,7 @@ func hsTakeHandler(server *Server, client *Client, command string, params []stri
}
account := client.Account()
_, err := server.accounts.VHostTake(account, vhost, config.Accounts.VHosts.UserRequests.Cooldown)
_, err := server.accounts.VHostTake(account, vhost, time.Duration(config.Accounts.VHosts.UserRequests.Cooldown))
if err != nil {
if throttled, ok := err.(*vhostThrottleExceeded); ok {
hsNotice(rb, fmt.Sprintf(client.t("You must wait an additional %v before taking a vhost"), throttled.timeRemaining))
+21 -23
View File
@@ -52,6 +52,7 @@ type IdleTimer struct {
quitTimeout time.Duration
state TimerState
timer *time.Timer
lastTouch time.Time
}
// Initialize sets up an IdleTimer and starts counting idle time;
@@ -61,9 +62,11 @@ func (it *IdleTimer) Initialize(session *Session) {
it.registerTimeout = RegisterTimeout
it.idleTimeout, it.quitTimeout = it.recomputeDurations()
registered := session.client.Registered()
now := time.Now().UTC()
it.Lock()
defer it.Unlock()
it.lastTouch = now
if registered {
it.state = TimerActive
} else {
@@ -82,7 +85,7 @@ func (it *IdleTimer) recomputeDurations() (idleTimeout, quitTimeout time.Duratio
}
idleTimeout = DefaultIdleTimeout
if it.session.client.isTor {
if it.session.isTor {
idleTimeout = TorIdleTimeout
}
@@ -92,10 +95,12 @@ func (it *IdleTimer) recomputeDurations() (idleTimeout, quitTimeout time.Duratio
func (it *IdleTimer) Touch() {
idleTimeout, quitTimeout := it.recomputeDurations()
now := time.Now().UTC()
it.Lock()
defer it.Unlock()
it.idleTimeout, it.quitTimeout = idleTimeout, quitTimeout
it.lastTouch = now
// a touch transitions TimerUnregistered or TimerIdle into TimerActive
if it.state != TimerDead {
it.state = TimerActive
@@ -103,6 +108,13 @@ func (it *IdleTimer) Touch() {
}
}
func (it *IdleTimer) LastTouch() (result time.Time) {
it.Lock()
result = it.lastTouch
it.Unlock()
return
}
func (it *IdleTimer) processTimeout() {
idleTimeout, quitTimeout := it.recomputeDurations()
@@ -322,9 +334,6 @@ const (
// BrbDead is the state of a client after its timeout has expired; it will be removed
// and therefore new sessions cannot be attached to it
BrbDead
// BrbSticky allows a client to remain online without sessions, with no timeout.
// This is not used yet.
BrbSticky
)
type BrbTimer struct {
@@ -345,16 +354,16 @@ func (bt *BrbTimer) Initialize(client *Client) {
// attempts to enable BRB for a client, returns whether it succeeded
func (bt *BrbTimer) Enable() (success bool, duration time.Duration) {
if !bt.client.Registered() || bt.client.ResumeID() == "" {
return
}
// TODO make this configurable
duration = ResumeableTotalTimeout
bt.client.stateMutex.Lock()
defer bt.client.stateMutex.Unlock()
if !bt.client.registered || bt.client.alwaysOn || bt.client.resumeID == "" {
return
}
switch bt.state {
case BrbDisabled, BrbEnabled:
bt.state = BrbEnabled
@@ -366,8 +375,6 @@ func (bt *BrbTimer) Enable() (success bool, duration time.Duration) {
bt.brbAt = time.Now().UTC()
}
success = true
case BrbSticky:
success = true
default:
// BrbDead
success = false
@@ -416,6 +423,10 @@ func (bt *BrbTimer) processTimeout() {
bt.client.stateMutex.Lock()
defer bt.client.stateMutex.Unlock()
if bt.client.alwaysOn {
return
}
switch bt.state {
case BrbDisabled, BrbEnabled:
if len(bt.client.sessions) == 0 {
@@ -432,16 +443,3 @@ func (bt *BrbTimer) processTimeout() {
}
bt.resetTimeout()
}
// sets a client to be "sticky", i.e., indefinitely exempt from removal for
// lack of sessions
func (bt *BrbTimer) SetSticky() (success bool) {
bt.client.stateMutex.Lock()
defer bt.client.stateMutex.Unlock()
if bt.state != BrbDead {
success = true
bt.state = BrbSticky
}
bt.resetTimeout()
return
}
+5 -5
View File
@@ -9,9 +9,9 @@ import (
)
func TestZncTimestampParser(t *testing.T) {
assertEqual(zncWireTimeToTime("1558338348.988"), time.Unix(1558338348, 988000000), t)
assertEqual(zncWireTimeToTime("1558338348.9"), time.Unix(1558338348, 900000000), t)
assertEqual(zncWireTimeToTime("1558338348"), time.Unix(1558338348, 0), t)
assertEqual(zncWireTimeToTime(".988"), time.Unix(0, 988000000), t)
assertEqual(zncWireTimeToTime("garbage"), time.Unix(0, 0), t)
assertEqual(zncWireTimeToTime("1558338348.988"), time.Unix(1558338348, 988000000).UTC(), t)
assertEqual(zncWireTimeToTime("1558338348.9"), time.Unix(1558338348, 900000000).UTC(), t)
assertEqual(zncWireTimeToTime("1558338348"), time.Unix(1558338348, 0).UTC(), t)
assertEqual(zncWireTimeToTime(".988"), time.Unix(0, 988000000).UTC(), t)
assertEqual(zncWireTimeToTime("garbage"), time.Unix(0, 0).UTC(), t)
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package mysql
import (
"time"
)
type Config struct {
// these are intended to be written directly into the config file:
Enabled bool
Host string
Port int
User string
Password string
HistoryDatabase string `yaml:"history-database"`
Timeout time.Duration
// XXX these are copied from elsewhere in the config:
ExpireTime time.Duration
}
+557
View File
@@ -0,0 +1,557 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package mysql
import (
"bytes"
"context"
"database/sql"
"fmt"
"runtime/debug"
"sync"
"sync/atomic"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/logger"
"github.com/oragono/oragono/irc/utils"
)
const (
// maximum length in bytes of any message target (nickname or channel name) in its
// canonicalized (i.e., casefolded) state:
MaxTargetLength = 64
// latest schema of the db
latestDbSchema = "1"
keySchemaVersion = "db.version"
cleanupRowLimit = 50
cleanupPauseTime = 10 * time.Minute
)
type MySQL struct {
timeout int64
db *sql.DB
logger *logger.Manager
insertHistory *sql.Stmt
insertSequence *sql.Stmt
insertConversation *sql.Stmt
stateMutex sync.Mutex
config Config
}
func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
mysql.logger = logger
mysql.SetConfig(config)
}
func (mysql *MySQL) SetConfig(config Config) {
atomic.StoreInt64(&mysql.timeout, int64(config.Timeout))
mysql.stateMutex.Lock()
mysql.config = config
mysql.stateMutex.Unlock()
}
func (mysql *MySQL) getExpireTime() (expireTime time.Duration) {
mysql.stateMutex.Lock()
expireTime = mysql.config.ExpireTime
mysql.stateMutex.Unlock()
return
}
func (m *MySQL) Open() (err error) {
var address string
if m.config.Port != 0 {
address = fmt.Sprintf("tcp(%s:%d)", m.config.Host, m.config.Port)
}
m.db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@%s/%s", m.config.User, m.config.Password, address, m.config.HistoryDatabase))
if err != nil {
return err
}
err = m.fixSchemas()
if err != nil {
return err
}
err = m.prepareStatements()
if err != nil {
return err
}
go m.cleanupLoop()
return nil
}
func (mysql *MySQL) fixSchemas() (err error) {
_, err = mysql.db.Exec(`CREATE TABLE IF NOT EXISTS metadata (
key_name VARCHAR(32) primary key,
value VARCHAR(32) NOT NULL
) CHARSET=ascii COLLATE=ascii_bin;`)
if err != nil {
return err
}
var schema string
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaVersion).Scan(&schema)
if err == sql.ErrNoRows {
err = mysql.createTables()
if err != nil {
return
}
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaVersion, latestDbSchema)
if err != nil {
return
}
} else if err == nil && schema != latestDbSchema {
// TODO figure out what to do about schema changes
return &utils.IncompatibleSchemaError{CurrentVersion: schema, RequiredVersion: latestDbSchema}
} else {
return err
}
return nil
}
func (mysql *MySQL) createTables() (err error) {
_, err = mysql.db.Exec(`CREATE TABLE history (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
data BLOB NOT NULL,
msgid BINARY(16) NOT NULL,
KEY (msgid(4))
) CHARSET=ascii COLLATE=ascii_bin;`)
if err != nil {
return err
}
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE sequence (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
target VARBINARY(%[1]d) NOT NULL,
nanotime BIGINT UNSIGNED NOT NULL,
history_id BIGINT NOT NULL,
KEY (target, nanotime),
KEY (history_id)
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
if err != nil {
return err
}
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE conversations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
lower_target VARBINARY(%[1]d) NOT NULL,
upper_target VARBINARY(%[1]d) NOT NULL,
nanotime BIGINT UNSIGNED NOT NULL,
history_id BIGINT NOT NULL,
KEY (lower_target, upper_target, nanotime),
KEY (history_id)
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
if err != nil {
return err
}
return nil
}
func (mysql *MySQL) cleanupLoop() {
defer func() {
if r := recover(); r != nil {
mysql.logger.Error("mysql",
fmt.Sprintf("Panic in cleanup routine: %v\n%s", r, debug.Stack()))
time.Sleep(cleanupPauseTime)
go mysql.cleanupLoop()
}
}()
for {
expireTime := mysql.getExpireTime()
if expireTime != 0 {
for {
startTime := time.Now()
rowsDeleted, err := mysql.doCleanup(expireTime)
elapsed := time.Now().Sub(startTime)
mysql.logError("error during row cleanup", err)
// keep going as long as we're accomplishing significant work
// (don't busy-wait on small numbers of rows expiring):
if rowsDeleted < (cleanupRowLimit / 10) {
break
}
// crude backpressure mechanism: if the database is slow,
// give it time to process other queries
time.Sleep(elapsed)
}
}
time.Sleep(cleanupPauseTime)
}
}
func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
ids, maxNanotime, err := mysql.selectCleanupIDs(age)
if len(ids) == 0 {
mysql.logger.Debug("mysql", "found no rows to clean up")
return
}
mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime)))
// can't use ? binding for a variable number of arguments, build the IN clause manually
var inBuf bytes.Buffer
inBuf.WriteByte('(')
for i, id := range ids {
if i != 0 {
inBuf.WriteRune(',')
}
fmt.Fprintf(&inBuf, "%d", id)
}
inBuf.WriteRune(')')
_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
if err != nil {
return
}
_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
if err != nil {
return
}
_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
if err != nil {
return
}
count = len(ids)
return
}
func (mysql *MySQL) selectCleanupIDs(age time.Duration) (ids []uint64, maxNanotime int64, err error) {
rows, err := mysql.db.Query(`
SELECT history.id, sequence.nanotime
FROM history
LEFT JOIN sequence ON history.id = sequence.history_id
ORDER BY history.id LIMIT ?;`, cleanupRowLimit)
if err != nil {
return
}
defer rows.Close()
// a history ID may have 0-2 rows in sequence: 1 for a channel entry,
// 2 for a DM, 0 if the data is inconsistent. therefore, deduplicate
// and delete anything that doesn't have a sequence entry:
idset := make(map[uint64]struct{}, cleanupRowLimit)
threshold := time.Now().Add(-age).UnixNano()
for rows.Next() {
var id uint64
var nanotime sql.NullInt64
err = rows.Scan(&id, &nanotime)
if err != nil {
return
}
if !nanotime.Valid || nanotime.Int64 < threshold {
idset[id] = struct{}{}
if nanotime.Valid && nanotime.Int64 > maxNanotime {
maxNanotime = nanotime.Int64
}
}
}
ids = make([]uint64, len(idset))
i := 0
for id := range idset {
ids[i] = id
i++
}
return
}
func (mysql *MySQL) prepareStatements() (err error) {
mysql.insertHistory, err = mysql.db.Prepare(`INSERT INTO history
(data, msgid) VALUES (?, ?);`)
if err != nil {
return
}
mysql.insertSequence, err = mysql.db.Prepare(`INSERT INTO sequence
(target, nanotime, history_id) VALUES (?, ?, ?);`)
if err != nil {
return
}
mysql.insertConversation, err = mysql.db.Prepare(`INSERT INTO conversations
(lower_target, upper_target, nanotime, history_id) VALUES (?, ?, ?, ?);`)
if err != nil {
return
}
return
}
func (mysql *MySQL) getTimeout() time.Duration {
return time.Duration(atomic.LoadInt64(&mysql.timeout))
}
func (mysql *MySQL) logError(context string, err error) (quit bool) {
if err != nil {
mysql.logger.Error("mysql", context, err.Error())
return true
}
return false
}
func (mysql *MySQL) AddChannelItem(target string, item history.Item) (err error) {
if mysql.db == nil {
return
}
if target == "" {
return utils.ErrInvalidParams
}
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
id, err := mysql.insertBase(ctx, item)
if err != nil {
return
}
err = mysql.insertSequenceEntry(ctx, target, item.Message.Time, id)
return
}
func (mysql *MySQL) insertSequenceEntry(ctx context.Context, target string, messageTime time.Time, id int64) (err error) {
_, err = mysql.insertSequence.ExecContext(ctx, target, messageTime.UnixNano(), id)
mysql.logError("could not insert sequence entry", err)
return
}
func (mysql *MySQL) insertConversationEntry(ctx context.Context, sender, recipient string, messageTime time.Time, id int64) (err error) {
lower, higher := stringMinMax(sender, recipient)
_, err = mysql.insertConversation.ExecContext(ctx, lower, higher, messageTime.UnixNano(), id)
mysql.logError("could not insert conversations entry", err)
return
}
func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) {
value, err := marshalItem(&item)
if mysql.logError("could not marshal item", err) {
return
}
msgidBytes, err := decodeMsgid(item.Message.Msgid)
if mysql.logError("could not decode msgid", err) {
return
}
result, err := mysql.insertHistory.ExecContext(ctx, value, msgidBytes)
if mysql.logError("could not insert item", err) {
return
}
id, err = result.LastInsertId()
if mysql.logError("could not insert item", err) {
return
}
return
}
func stringMinMax(first, second string) (min, max string) {
if first < second {
return first, second
} else {
return second, first
}
}
func (mysql *MySQL) AddDirectMessage(sender, recipient string, senderPersistent, recipientPersistent bool, item history.Item) (err error) {
if mysql.db == nil {
return
}
if !(senderPersistent || recipientPersistent) {
return
}
if sender == "" || recipient == "" {
return utils.ErrInvalidParams
}
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
id, err := mysql.insertBase(ctx, item)
if err != nil {
return
}
if senderPersistent {
mysql.insertSequenceEntry(ctx, sender, item.Message.Time, id)
if err != nil {
return
}
}
if recipientPersistent && sender != recipient {
err = mysql.insertSequenceEntry(ctx, recipient, item.Message.Time, id)
if err != nil {
return
}
}
err = mysql.insertConversationEntry(ctx, sender, recipient, item.Message.Time, id)
return
}
func (mysql *MySQL) msgidToTime(ctx context.Context, msgid string) (result time.Time, err error) {
// in theory, we could optimize out a roundtrip to the database by using a subquery instead:
// sequence.nanotime > (
// SELECT sequence.nanotime FROM sequence, history
// WHERE sequence.history_id = history.id AND history.msgid = ?
// LIMIT 1)
// however, this doesn't handle the BETWEEN case with one or two msgids, where we
// don't initially know whether the interval is going forwards or backwards. to simplify
// the logic, resolve msgids to timestamps "manually" in all cases, using a separate query.
decoded, err := decodeMsgid(msgid)
if err != nil {
return
}
row := mysql.db.QueryRowContext(ctx, `
SELECT sequence.nanotime FROM sequence
INNER JOIN history ON history.id = sequence.history_id
WHERE history.msgid = ? LIMIT 1;`, decoded)
var nanotime int64
err = row.Scan(&nanotime)
if mysql.logError("could not resolve msgid to time", err) {
return
}
result = time.Unix(0, nanotime).UTC()
return
}
func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) {
rows, err := mysql.db.QueryContext(ctx, query, args...)
if mysql.logError("could not select history items", err) {
return
}
defer rows.Close()
for rows.Next() {
var blob []byte
var item history.Item
err = rows.Scan(&blob)
if mysql.logError("could not scan history item", err) {
return
}
err = unmarshalItem(blob, &item)
if mysql.logError("could not unmarshal history item", err) {
return
}
results = append(results, item)
}
return
}
func (mysql *MySQL) betweenTimestamps(ctx context.Context, sender, recipient string, after, before, cutoff time.Time, limit int) (results []history.Item, err error) {
useSequence := true
var lowerTarget, upperTarget string
if sender != "" {
lowerTarget, upperTarget = stringMinMax(sender, recipient)
useSequence = false
}
table := "sequence"
if !useSequence {
table = "conversations"
}
after, before, ascending := history.MinMaxAsc(after, before, cutoff)
direction := "ASC"
if !ascending {
direction = "DESC"
}
var queryBuf bytes.Buffer
args := make([]interface{}, 0, 6)
fmt.Fprintf(&queryBuf,
"SELECT history.data from history INNER JOIN %[1]s ON history.id = %[1]s.history_id WHERE", table)
if useSequence {
fmt.Fprintf(&queryBuf, " sequence.target = ?")
args = append(args, recipient)
} else {
fmt.Fprintf(&queryBuf, " conversations.lower_target = ? AND conversations.upper_target = ?")
args = append(args, lowerTarget)
args = append(args, upperTarget)
}
if !after.IsZero() {
fmt.Fprintf(&queryBuf, " AND %s.nanotime > ?", table)
args = append(args, after.UnixNano())
}
if !before.IsZero() {
fmt.Fprintf(&queryBuf, " AND %s.nanotime < ?", table)
args = append(args, before.UnixNano())
}
fmt.Fprintf(&queryBuf, " ORDER BY %[1]s.nanotime %[2]s LIMIT ?;", table, direction)
args = append(args, limit)
results, err = mysql.selectItems(ctx, queryBuf.String(), args...)
if err == nil && !ascending {
history.Reverse(results)
}
return
}
func (mysql *MySQL) Close() {
// closing the database will close our prepared statements as well
if mysql.db != nil {
mysql.db.Close()
}
mysql.db = nil
}
// implements history.Sequence, emulating a single history buffer (for a channel,
// a single user's DMs, or a DM conversation)
type mySQLHistorySequence struct {
mysql *MySQL
sender string
recipient string
cutoff time.Time
}
func (s *mySQLHistorySequence) Between(start, end history.Selector, limit int) (results []history.Item, complete bool, err error) {
ctx, cancel := context.WithTimeout(context.Background(), s.mysql.getTimeout())
defer cancel()
startTime := start.Time
if start.Msgid != "" {
startTime, err = s.mysql.msgidToTime(ctx, start.Msgid)
if err != nil {
return nil, false, err
}
}
endTime := end.Time
if end.Msgid != "" {
endTime, err = s.mysql.msgidToTime(ctx, end.Msgid)
if err != nil {
return nil, false, err
}
}
results, err = s.mysql.betweenTimestamps(ctx, s.sender, s.recipient, startTime, endTime, s.cutoff, limit)
return results, (err == nil), err
}
func (s *mySQLHistorySequence) Around(start history.Selector, limit int) (results []history.Item, err error) {
return history.GenericAround(s, start, limit)
}
func (mysql *MySQL) MakeSequence(sender, recipient string, cutoff time.Time) history.Sequence {
return &mySQLHistorySequence{
sender: sender,
recipient: recipient,
mysql: mysql,
cutoff: cutoff,
}
}
+23
View File
@@ -0,0 +1,23 @@
package mysql
import (
"encoding/json"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/utils"
)
// 123 / '{' is the magic number that means JSON;
// if we want to do a binary encoding later, we just have to add different magic version numbers
func marshalItem(item *history.Item) (result []byte, err error) {
return json.Marshal(item)
}
func unmarshalItem(data []byte, result *history.Item) (err error) {
return json.Unmarshal(data, result)
}
func decodeMsgid(msgid string) ([]byte, error) {
return utils.B32Encoder.DecodeString(msgid)
}
+10 -8
View File
@@ -43,13 +43,15 @@ func performNickChange(server *Server, client *Client, target *Client, session *
hadNick := target.HasNick()
origNickMask := target.NickMaskString()
details := target.Details()
err := client.server.clients.SetNick(target, session, nickname)
assignedNickname, err := client.server.clients.SetNick(target, session, nickname)
if err == errNicknameInUse {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, nickname, client.t("Nickname is already in use"))
} else if err == errNicknameReserved {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, nickname, client.t("Nickname is reserved by a different account"))
} else if err == errNicknameInvalid {
rb.Add(nil, server.name, ERR_ERRONEUSNICKNAME, currentNick, utils.SafeErrorParam(nickname), client.t("Erroneous nickname"))
} else if err == errCantChangeNick {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, utils.SafeErrorParam(nickname), client.t(err.Error()))
} else if err != nil {
rb.Add(nil, server.name, ERR_UNKNOWNERROR, currentNick, "NICK", fmt.Sprintf(client.t("Could not set or change nickname: %s"), err.Error()))
}
@@ -64,26 +66,26 @@ func performNickChange(server *Server, client *Client, target *Client, session *
AccountName: details.accountName,
Message: message,
}
histItem.Params[0] = nickname
histItem.Params[0] = assignedNickname
client.server.logger.Debug("nick", fmt.Sprintf("%s changed nickname to %s [%s]", origNickMask, nickname, client.NickCasefolded()))
client.server.logger.Debug("nick", fmt.Sprintf("%s changed nickname to %s [%s]", origNickMask, assignedNickname, client.NickCasefolded()))
if hadNick {
if client == target {
target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("$%s$r changed nickname to %s"), details.nick, nickname))
target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("$%s$r changed nickname to %s"), details.nick, assignedNickname))
} else {
target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("Operator %s changed nickname of $%s$r to %s"), client.Nick(), details.nick, nickname))
target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("Operator %s changed nickname of $%s$r to %s"), client.Nick(), details.nick, assignedNickname))
}
target.server.whoWas.Append(details.WhoWas)
rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", nickname)
rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", assignedNickname)
for session := range target.Friends() {
if session != rb.session {
session.sendFromClientInternal(false, message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", nickname)
session.sendFromClientInternal(false, message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", assignedNickname)
}
}
}
for _, channel := range client.Channels() {
channel.history.Add(histItem)
channel.AddHistoryItem(histItem)
}
if target.Registered() {
+103 -25
View File
@@ -31,7 +31,7 @@ func servCmdRequiresNickRes(config *Config) bool {
}
func servCmdRequiresBouncerEnabled(config *Config) bool {
return config.Accounts.Bouncer.Enabled
return config.Accounts.Multiclient.Enabled
}
const (
@@ -147,7 +147,7 @@ an administrator can set use this command to set up user accounts.`,
help: `Syntax: $bSESSIONS [nickname]$b
SESSIONS lists information about the sessions currently attached, via
the server's bouncer functionality, to your nickname. An administrator
the server's multiclient functionality, to your nickname. An administrator
can use this command to list another user's sessions.`,
helpShort: `$bSESSIONS$b lists the sessions attached to a nickname.`,
enabled: servCmdRequiresBouncerEnabled,
@@ -217,7 +217,7 @@ information on the settings and their possible values, see HELP SET.`,
helpStrings: []string{
`Syntax $bSET <setting> <value>$b
Set modifies your account settings. The following settings are available:`,
SET modifies your account settings. The following settings are available:`,
`$bENFORCE$b
'enforce' lets you specify a custom enforcement mechanism for your registered
@@ -228,8 +228,8 @@ nicknames. Your options are:
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
`$bMULTICLIENT$b
If 'multiclient' 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),
@@ -247,6 +247,22 @@ lines for join and part. This provides more information about the context of
messages, but may be spammy. Your options are 'always', 'never', and the default
of 'commands-only' (the messages will be replayed in /HISTORY output, but not
during autoreplay).`,
`$bALWAYS-ON$b
'always-on' controls whether your nickname/identity will remain active
even while you are disconnected from the server. Your options are 'true',
'false', and 'default' (use the server default value).`,
`$bAUTOREPLAY-MISSED$b
'autoreplay-missed' is only effective for always-on clients. If enabled,
if you have at most one active session, the server will remember the time
you disconnect and then replay missed messages to you when you reconnect.
Your options are 'on' and 'off'.`,
`$bDM-HISTORY$b
'dm-history' is only effective for always-on clients. It lets you control
how the history of your direct messages is stored. Your options are:
1. 'off' [no history]
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]`,
},
authRequired: true,
enabled: servCmdRequiresAccreg,
@@ -332,23 +348,48 @@ func displaySetting(settingName string, settings AccountSettings, client *Client
case ReplayJoinsNever:
nsNotice(rb, client.t("You will not see JOINs and PARTs in /HISTORY output or in autoreplay"))
}
case "bouncer":
if !config.Accounts.Bouncer.Enabled {
case "multiclient":
if !config.Accounts.Multiclient.Enabled {
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, client.t("Bouncer functionality is currently enabled for your account, but you can opt out"))
case MulticlientAllowedServerDefault:
if config.Accounts.Multiclient.AllowedByDefault {
nsNotice(rb, client.t("Multiclient functionality is currently enabled for your account, but you can opt out"))
} else {
nsNotice(rb, client.t("Bouncer functionality is currently disabled for your account, but you can opt in"))
nsNotice(rb, client.t("Multiclient functionality is currently disabled for your account, but you can opt in"))
}
case BouncerDisallowedByUser:
nsNotice(rb, client.t("Bouncer functionality is currently disabled for your account"))
case BouncerAllowedByUser:
nsNotice(rb, client.t("Bouncer functionality is currently enabled for your account"))
case MulticlientDisallowedByUser:
nsNotice(rb, client.t("Multiclient functionality is currently disabled for your account"))
case MulticlientAllowedByUser:
nsNotice(rb, client.t("Multiclient functionality is currently enabled for your account"))
}
}
case "always-on":
stored := settings.AlwaysOn
actual := client.AlwaysOn()
nsNotice(rb, fmt.Sprintf(client.t("Your stored always-on setting is: %s"), persistentStatusToString(stored)))
if actual {
nsNotice(rb, client.t("Given current server settings, your client is always-on"))
} else {
nsNotice(rb, client.t("Given current server settings, your client is not always-on"))
}
case "autoreplay-missed":
stored := settings.AutoreplayMissed
if stored {
if client.AlwaysOn() {
nsNotice(rb, client.t("Autoreplay of missed messages is enabled"))
} else {
nsNotice(rb, client.t("You have enabled autoreplay of missed messages, but you can't receive them because your client isn't set to always-on"))
}
} else {
nsNotice(rb, client.t("Your account is not configured to receive autoreplayed missed messages"))
}
case "dm-history":
effectiveValue := historyEnabled(config.History.Persistent.DirectMessages, settings.DMHistory)
csNotice(rb, fmt.Sprintf(client.t("Your stored direct message history setting is: %s"), historyStatusToString(settings.DMHistory)))
csNotice(rb, fmt.Sprintf(client.t("Given current server settings, your direct message history setting is: %s"), historyStatusToString(effectiveValue)))
default:
nsNotice(rb, client.t("No such setting"))
}
@@ -399,17 +440,17 @@ func nsSetHandler(server *Server, client *Client, command string, params []strin
out.AutoreplayLines = newValue
return
}
case "bouncer":
var newValue BouncerAllowedSetting
case "multiclient":
var newValue MulticlientAllowedSetting
if strings.ToLower(params[1]) == "default" {
newValue = BouncerAllowedServerDefault
newValue = MulticlientAllowedServerDefault
} else {
var enabled bool
enabled, err = utils.StringToBool(params[1])
if enabled {
newValue = BouncerAllowedByUser
newValue = MulticlientAllowedByUser
} else {
newValue = BouncerDisallowedByUser
newValue = MulticlientDisallowedByUser
}
}
if err == nil {
@@ -429,6 +470,37 @@ func nsSetHandler(server *Server, client *Client, command string, params []strin
return
}
}
case "always-on":
var newValue PersistentStatus
newValue, err = persistentStatusFromString(params[1])
// "opt-in" and "opt-out" don't make sense as user preferences
if err == nil && newValue != PersistentOptIn && newValue != PersistentOptOut {
munger = func(in AccountSettings) (out AccountSettings, err error) {
out = in
out.AlwaysOn = newValue
return
}
}
case "autoreplay-missed":
var newValue bool
newValue, err = utils.StringToBool(params[1])
if err == nil {
munger = func(in AccountSettings) (out AccountSettings, err error) {
out = in
out.AutoreplayMissed = newValue
return
}
}
case "dm-history":
var newValue HistoryStatus
newValue, err = historyStatusFromString(params[1])
if err == nil {
munger = func(in AccountSettings) (out AccountSettings, err error) {
out = in
out.DMHistory = newValue
return
}
}
default:
err = errInvalidParams
}
@@ -480,6 +552,9 @@ func nsGhostHandler(server *Server, client *Client, command string, params []str
} else if ghost == client {
nsNotice(rb, client.t("You can't GHOST yourself (try /QUIT instead)"))
return
} else if ghost.AlwaysOn() {
nsNotice(rb, client.t("You can't GHOST an always-on client"))
return
}
authorized := false
@@ -530,7 +605,7 @@ func nsIdentifyHandler(server *Server, client *Client, command string, params []
var username, passphrase string
if len(params) == 1 {
if client.certfp != "" {
if rb.session.certfp != "" {
username = params[0]
} else {
// XXX undocumented compatibility mode with other nickservs, allowing
@@ -553,8 +628,8 @@ func nsIdentifyHandler(server *Server, client *Client, command string, params []
}
// try certfp
if !loginSuccessful && client.certfp != "" {
err := server.accounts.AuthenticateByCertFP(client, "")
if !loginSuccessful && rb.session.certfp != "" {
err := server.accounts.AuthenticateByCertFP(client, rb.session.certfp, "")
loginSuccessful = (err == nil)
}
@@ -618,7 +693,7 @@ func nsRegisterHandler(server *Server, client *Client, command string, params []
email = params[1]
}
certfp := client.certfp
certfp := rb.session.certfp
if passphrase == "*" {
if certfp == "" {
nsNotice(rb, client.t("You must be connected with TLS and a client certificate to do this"))
@@ -658,7 +733,7 @@ func nsRegisterHandler(server *Server, client *Client, command string, params []
}
}
err := server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, client.certfp)
err := server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, rb.session.certfp)
if err == nil {
if callbackNamespace == "*" {
err = server.accounts.Verify(client, account, "")
@@ -876,6 +951,9 @@ func nsSessionsHandler(server *Server, client *Client, command string, params []
nsNotice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
nsNotice(rb, fmt.Sprintf(client.t("Created at: %s"), session.ctime.Format(time.RFC1123)))
nsNotice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123)))
if session.certfp != "" {
nsNotice(rb, fmt.Sprintf(client.t("Certfp: %s"), session.certfp))
}
}
}
+2 -2
View File
@@ -121,7 +121,7 @@ func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAcc
if message.Is512() {
rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message)
} else {
if message.IsMultiline() && rb.session.capabilities.Has(caps.Multiline) {
if rb.session.capabilities.Has(caps.Multiline) {
batch := rb.session.composeMultilineBatch(fromNickMask, fromAccount, tags, command, target, message)
rb.setNestedBatchTag(&batch[0])
rb.setNestedBatchTag(&batch[len(batch)-1])
@@ -292,5 +292,5 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
// Notice sends the client the given notice from the server.
func (rb *ResponseBuffer) Notice(text string) {
rb.Add(nil, rb.target.server.name, "NOTICE", rb.target.nick, text)
rb.Add(nil, rb.target.server.name, "NOTICE", rb.target.Nick(), text)
}
+104 -12
View File
@@ -24,8 +24,10 @@ import (
"github.com/goshuirc/irc-go/ircfmt"
"github.com/oragono/oragono/irc/caps"
"github.com/oragono/oragono/irc/connection_limits"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/logger"
"github.com/oragono/oragono/irc/modes"
"github.com/oragono/oragono/irc/mysql"
"github.com/oragono/oragono/irc/sno"
"github.com/tidwall/buntdb"
)
@@ -84,6 +86,7 @@ type Server struct {
signals chan os.Signal
snomasks SnoManager
store *buntdb.DB
historyDB mysql.MySQL
torLimiter connection_limits.TorLimiter
whoWas WhoWasList
stats Stats
@@ -122,7 +125,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
server.monitorManager.Initialize()
server.snomasks.Initialize()
if err := server.applyConfig(config, true); err != nil {
if err := server.applyConfig(config); err != nil {
return nil, err
}
@@ -143,6 +146,8 @@ func (server *Server) Shutdown() {
if err := server.store.Close(); err != nil {
server.logger.Error("shutdown", fmt.Sprintln("Could not close datastore:", err))
}
server.historyDB.Close()
}
// Run starts the server.
@@ -316,7 +321,7 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
// client MUST send PASS if necessary, or authenticate with SASL if necessary,
// before completing the other registration commands
authOutcome := c.isAuthorized(server.Config())
authOutcome := c.isAuthorized(server.Config(), session)
var quitMessage string
switch authOutcome {
case authFailPass:
@@ -376,7 +381,7 @@ func (server *Server) playRegistrationBurst(session *Session) {
// continue registration
d := c.Details()
server.logger.Info("localconnect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", d.nick, d.username, d.realname))
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", d.nick, d.username, c.RawHostname(), c.IPString(), d.realname))
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", d.nick, d.username, session.rawHostname, session.IP().String(), d.realname))
// send welcome text
//NOTE(dan): we specifically use the NICK here instead of the nickmask
@@ -503,8 +508,12 @@ func (client *Client) getWhoisOf(target *Client, rb *ResponseBuffer) {
rb.Add(nil, client.server.name, RPL_WHOISBOT, cnick, tnick, ircfmt.Unescape(fmt.Sprintf(client.t("is a $bBot$b on %s"), client.server.Config().Network.Name)))
}
if target.certfp != "" && (client.HasMode(modes.Operator) || client == target) {
rb.Add(nil, client.server.name, RPL_WHOISCERTFP, cnick, tnick, fmt.Sprintf(client.t("has client certificate fingerprint %s"), target.certfp))
if client == target || client.HasMode(modes.Operator) {
for _, session := range target.Sessions() {
if session.certfp != "" {
rb.Add(nil, client.server.name, RPL_WHOISCERTFP, cnick, tnick, fmt.Sprintf(client.t("has client certificate fingerprint %s"), session.certfp))
}
}
}
rb.Add(nil, client.server.name, RPL_WHOISIDLE, cnick, tnick, strconv.FormatUint(target.IdleSeconds(), 10), strconv.FormatInt(target.SignonTime(), 10), client.t("seconds idle, signon time"))
}
@@ -550,7 +559,7 @@ func (server *Server) rehash() error {
return fmt.Errorf("Error loading config file config: %s", err.Error())
}
err = server.applyConfig(config, false)
err = server.applyConfig(config)
if err != nil {
return fmt.Errorf("Error applying config changes: %s", err.Error())
}
@@ -558,7 +567,10 @@ func (server *Server) rehash() error {
return nil
}
func (server *Server) applyConfig(config *Config, initial bool) (err error) {
func (server *Server) applyConfig(config *Config) (err error) {
oldConfig := server.Config()
initial := oldConfig == nil
if initial {
server.configFilename = config.Filename
server.name = config.Server.Name
@@ -568,7 +580,7 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
// enforce configs that can't be changed after launch:
if server.name != config.Server.Name {
return fmt.Errorf("Server name cannot be changed after launching the server, rehash aborted")
} else if server.Config().Datastore.Path != config.Datastore.Path {
} else if oldConfig.Datastore.Path != config.Datastore.Path {
return fmt.Errorf("Datastore path cannot be changed after launching the server, rehash aborted")
} else if globalCasemappingSetting != config.Server.Casemapping {
return fmt.Errorf("Casemapping cannot be changed after launching the server, rehash aborted")
@@ -576,7 +588,6 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
}
server.logger.Info("server", "Using config file", server.configFilename)
oldConfig := server.Config()
// first, reload config sections for functionality implemented in subpackages:
wasLoggingRawIO := !initial && server.logger.IsLoggingRawIO()
@@ -609,14 +620,13 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
if !oldConfig.Channels.Registration.Enabled {
server.channels.loadRegisteredChannels(config)
}
// resize history buffers as needed
if oldConfig.History != config.History {
for _, channel := range server.channels.Channels() {
channel.history.Resize(config.History.ChannelLength, config.History.AutoresizeWindow)
channel.resizeHistory(config)
}
for _, client := range server.clients.AllClients() {
client.history.Resize(config.History.ClientLength, config.History.AutoresizeWindow)
client.resizeHistory(config)
}
}
}
@@ -658,6 +668,10 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
if err := server.loadDatastore(config); err != nil {
return err
}
} else {
if config.Datastore.MySQL.Enabled && config.Datastore.MySQL != oldConfig.Datastore.MySQL {
server.historyDB.SetConfig(config.Datastore.MySQL)
}
}
server.setupPprofListener(config)
@@ -778,6 +792,15 @@ func (server *Server) loadDatastore(config *Config) error {
server.channels.Initialize(server)
server.accounts.Initialize(server)
if config.Datastore.MySQL.Enabled {
server.historyDB.Initialize(server.logger, config.Datastore.MySQL)
err = server.historyDB.Open()
if err != nil {
server.logger.Error("internal", "could not connect to mysql", err.Error())
return err
}
}
return nil
}
@@ -835,6 +858,75 @@ func (server *Server) setupListeners(config *Config) (err error) {
return
}
// Gets the abstract sequence from which we're going to query history;
// we may already know the channel we're querying, or we may have
// to look it up via a string target. This function is responsible for
// privilege checking.
func (server *Server) GetHistorySequence(providedChannel *Channel, client *Client, target string) (channel *Channel, sequence history.Sequence, err error) {
config := server.Config()
var sender, recipient string
var hist *history.Buffer
if target == "*" {
persistent, ephemeral, target := client.historyStatus(config)
if persistent {
recipient = target
} else if ephemeral {
hist = &client.history
} else {
return
}
} else {
channel = providedChannel
if channel == nil {
channel = server.channels.Get(target)
}
if channel != nil {
if !channel.hasClient(client) {
err = errInsufficientPrivs
return
}
persistent, ephemeral, cfTarget := channel.historyStatus(config)
if persistent {
recipient = cfTarget
} else if ephemeral {
hist = &channel.history
} else {
return
}
} else {
sender = client.NickCasefolded()
var cfTarget string
cfTarget, err = CasefoldName(target)
if err != nil {
return
}
recipient = cfTarget
if !client.AlwaysOn() {
hist = &client.history
}
}
}
var cutoff time.Time
if config.History.Restrictions.ExpireTime != 0 {
cutoff = time.Now().UTC().Add(-time.Duration(config.History.Restrictions.ExpireTime))
}
if config.History.Restrictions.EnforceRegistrationDate {
regCutoff := client.historyCutoff()
regCutoff.Add(-time.Duration(config.History.Restrictions.GracePeriod))
// take the earlier of the two cutoffs
if regCutoff.After(cutoff) {
cutoff = regCutoff
}
}
if hist != nil {
sequence = hist.MakeSequence(recipient, cutoff)
} else if recipient != "" {
sequence = server.historyDB.MakeSequence(sender, recipient, cutoff)
}
return
}
// elistMatcher takes and matches ELIST conditions
type elistMatcher struct {
MinClientsActive bool
+19 -1
View File
@@ -26,15 +26,33 @@ func (s *Stats) Add() {
s.mutex.Unlock()
}
// Activates a registered client, e.g., for the initial attach to a persistent client
func (s *Stats) AddRegistered(invisible, operator bool) {
s.mutex.Lock()
if invisible {
s.Invisible += 1
}
if operator {
s.Operators += 1
}
s.Total += 1
s.setMax()
s.mutex.Unlock()
}
// Transition a client from unregistered to registered
func (s *Stats) Register() {
s.mutex.Lock()
s.Unknown -= 1
s.Total += 1
s.setMax()
s.mutex.Unlock()
}
func (s *Stats) setMax() {
if s.Max < s.Total {
s.Max = s.Total
}
s.mutex.Unlock()
}
// Modify the Invisible count
+21 -2
View File
@@ -5,7 +5,13 @@ package utils
import (
"errors"
"fmt"
"strings"
"time"
)
const (
IRCv3TimestampFormat = "2006-01-02T15:04:05.000Z"
)
var (
@@ -45,9 +51,9 @@ func ArgsToStrings(maxLength int, arguments []string, delim string) []string {
func StringToBool(str string) (result bool, err error) {
switch strings.ToLower(str) {
case "on", "true", "t", "yes", "y":
case "on", "true", "t", "yes", "y", "enabled":
result = true
case "off", "false", "f", "no", "n":
case "off", "false", "f", "no", "n", "disabled":
result = false
default:
err = ErrInvalidParams
@@ -63,3 +69,16 @@ func SafeErrorParam(param string) string {
}
return param
}
type IncompatibleSchemaError struct {
CurrentVersion string
RequiredVersion string
}
func (err *IncompatibleSchemaError) Error() string {
return fmt.Sprintf("Database requires update. Expected schema v%s, got v%s", err.RequiredVersion, err.CurrentVersion)
}
func NanoToTimestamp(nanotime int64) string {
return time.Unix(0, nanotime).UTC().Format(IRCv3TimestampFormat)
}
-8
View File
@@ -18,14 +18,6 @@ var (
validHostnameLabelRegexp = regexp.MustCompile(`^[0-9A-Za-z.\-]+$`)
)
// AddrIsLocal returns whether the address is from a trusted local connection (loopback or unix).
func AddrIsLocal(addr net.Addr) bool {
if tcpaddr, ok := addr.(*net.TCPAddr); ok {
return tcpaddr.IP.IsLoopback()
}
return AddrIsUnix(addr)
}
// AddrToIP returns the IP address for a net.Addr; unix domain sockets are treated as IPv4 loopback
func AddrToIP(addr net.Addr) net.IP {
if tcpaddr, ok := addr.(*net.TCPAddr); ok {
+5 -8
View File
@@ -23,9 +23,9 @@ type MessagePair struct {
// SplitMessage represents a message that's been split for sending.
// Two possibilities:
// (a) Standard message that can be relayed on a single 512-byte line
// (MessagePair contains the message, Wrapped == nil)
// (MessagePair contains the message, Split == nil)
// (b) multiline message that was split on the client side
// (Message == "", Wrapped contains the split lines)
// (Message == "", Split contains the split lines)
type SplitMessage struct {
Message string
Msgid string
@@ -36,7 +36,7 @@ type SplitMessage struct {
func MakeMessage(original string) (result SplitMessage) {
result.Message = original
result.Msgid = GenerateSecretToken()
result.Time = time.Now().UTC()
result.SetTime()
return
}
@@ -52,7 +52,8 @@ func (sm *SplitMessage) Append(message string, concat bool) {
}
func (sm *SplitMessage) SetTime() {
sm.Time = time.Now().UTC()
// strip the monotonic time, it's a potential source of problems:
sm.Time = time.Now().UTC().Round(0)
}
func (sm *SplitMessage) LenLines() int {
@@ -88,10 +89,6 @@ func (sm *SplitMessage) IsRestrictedCTCPMessage() bool {
return false
}
func (sm *SplitMessage) IsMultiline() bool {
return sm.Message == "" && len(sm.Split) != 0
}
func (sm *SplitMessage) Is512() bool {
return sm.Message != ""
}
+16 -4
View File
@@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"time"
"github.com/oragono/oragono/irc/history"
)
type zncCommandHandler func(client *Client, command string, params []string, rb *ResponseBuffer)
@@ -43,7 +45,7 @@ func zncWireTimeToTime(str string) (result time.Time) {
}
seconds, _ := strconv.ParseInt(secondsPortion, 10, 64)
fraction, _ := strconv.ParseFloat(fracPortion, 64)
return time.Unix(seconds, int64(fraction*1000000000))
return time.Unix(seconds, int64(fraction*1000000000)).UTC()
}
type zncPlaybackTimes struct {
@@ -89,10 +91,8 @@ func zncPlaybackHandler(client *Client, command string, params []string, rb *Res
// 3.3 When the client sends a subsequent redundant JOIN line for those
// channels; redundant JOIN is a complete no-op so we won't replay twice
config := client.server.Config()
if params[1] == "*" {
items, _ := client.history.Between(after, before, false, config.History.ChathistoryMax)
client.replayPrivmsgHistory(rb, items, true)
zncPlayPrivmsgs(client, rb, after, before)
} else {
targets = make(StringSet)
// TODO actually handle nickname targets
@@ -116,3 +116,15 @@ func zncPlaybackHandler(client *Client, command string, params []string, rb *Res
}
}
}
func zncPlayPrivmsgs(client *Client, rb *ResponseBuffer, after, before time.Time) {
_, sequence, _ := client.server.GetHistorySequence(nil, client, "*")
if sequence == nil {
return
}
zncMax := client.server.Config().History.ZNCMax
items, _, err := sequence.Between(history.Selector{Time: after}, history.Selector{Time: before}, zncMax)
if err == nil && len(items) != 0 {
client.replayPrivmsgHistory(rb, items, "", true)
}
}