mirror of
https://github.com/ergochat/ergo.git
synced 2024-12-22 18:52:41 +01:00
Merge pull request #304 from slingamn/history.1
draft/resume-0.2 implementation, message history support
This commit is contained in:
commit
f912f64f21
1
Makefile
1
Makefile
@ -20,6 +20,7 @@ test:
|
||||
python3 ./gencapdefs.py | diff - ${capdef_file}
|
||||
cd irc && go test . && go vet .
|
||||
cd irc/caps && go test . && go vet .
|
||||
cd irc/history && go test . && go vet .
|
||||
cd irc/isupport && go test . && go vet .
|
||||
cd irc/modes && go test . && go vet .
|
||||
cd irc/passwd && go test . && go vet .
|
||||
|
@ -107,7 +107,7 @@ CAPDEFS = [
|
||||
),
|
||||
CapDef(
|
||||
identifier="Resume",
|
||||
name="draft/resume",
|
||||
name="draft/resume-0.2",
|
||||
url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md",
|
||||
standard="proposed IRCv3",
|
||||
),
|
||||
|
@ -4,9 +4,6 @@
|
||||
package irc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -20,6 +17,7 @@ import (
|
||||
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
"github.com/oragono/oragono/irc/passwd"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
"github.com/tidwall/buntdb"
|
||||
)
|
||||
|
||||
@ -336,9 +334,7 @@ func (am *AccountManager) dispatchCallback(client *Client, casefoldedAccount str
|
||||
|
||||
func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccount string, callbackValue string) (code string, err error) {
|
||||
config := am.server.AccountConfig().Registration.Callbacks.Mailto
|
||||
buf := make([]byte, 16)
|
||||
rand.Read(buf)
|
||||
code = hex.EncodeToString(buf)
|
||||
code = utils.GenerateSecretToken()
|
||||
|
||||
subject := config.VerifyMessageSubject
|
||||
if subject == "" {
|
||||
@ -412,7 +408,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
|
||||
storedCode, err := tx.Get(verificationCodeKey)
|
||||
if err == nil {
|
||||
// this is probably unnecessary
|
||||
if storedCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(storedCode)) == 1 {
|
||||
if storedCode == "" || utils.SecretTokensMatch(storedCode, code) {
|
||||
success = true
|
||||
}
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ const (
|
||||
// https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md
|
||||
Rename Capability = iota
|
||||
|
||||
// Resume is the proposed IRCv3 capability named "draft/resume":
|
||||
// Resume is the proposed IRCv3 capability named "draft/resume-0.2":
|
||||
// https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md
|
||||
Resume Capability = iota
|
||||
|
||||
@ -112,7 +112,7 @@ var (
|
||||
"draft/message-tags-0.2",
|
||||
"multi-prefix",
|
||||
"draft/rename",
|
||||
"draft/resume",
|
||||
"draft/resume-0.2",
|
||||
"sasl",
|
||||
"server-time",
|
||||
"sts",
|
||||
|
174
irc/channel.go
174
irc/channel.go
@ -7,7 +7,6 @@ package irc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@ -16,7 +15,9 @@ import (
|
||||
|
||||
"github.com/goshuirc/irc-go/ircmsg"
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
"github.com/oragono/oragono/irc/history"
|
||||
"github.com/oragono/oragono/irc/modes"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
)
|
||||
|
||||
// Channel represents a channel that clients can join.
|
||||
@ -39,6 +40,7 @@ type Channel struct {
|
||||
topicSetTime time.Time
|
||||
userLimit uint64
|
||||
accountToUMode map[string]modes.Mode
|
||||
history history.Buffer
|
||||
}
|
||||
|
||||
// NewChannel creates a new channel from a `Server` and a `name`
|
||||
@ -65,14 +67,18 @@ func NewChannel(s *Server, name string, regInfo *RegisteredChannel) *Channel {
|
||||
accountToUMode: make(map[string]modes.Mode),
|
||||
}
|
||||
|
||||
config := s.Config()
|
||||
|
||||
if regInfo != nil {
|
||||
channel.applyRegInfo(regInfo)
|
||||
} else {
|
||||
for _, mode := range s.DefaultChannelModes() {
|
||||
for _, mode := range config.Channels.defaultModes {
|
||||
channel.flags.SetMode(mode, true)
|
||||
}
|
||||
}
|
||||
|
||||
channel.history.Initialize(config.History.ChannelLength)
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
@ -214,9 +220,7 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
|
||||
prefix := modes.Prefixes(isMultiPrefix)
|
||||
if buffer.Len()+len(nick)+len(prefix)+1 > maxNamLen {
|
||||
namesLines = append(namesLines, buffer.String())
|
||||
// memset(&buffer, 0, sizeof(bytes.Buffer));
|
||||
var newBuffer bytes.Buffer
|
||||
buffer = newBuffer
|
||||
buffer.Reset()
|
||||
}
|
||||
if buffer.Len() > 0 {
|
||||
buffer.WriteString(" ")
|
||||
@ -344,11 +348,7 @@ func (channel *Channel) IsFull() bool {
|
||||
// CheckKey returns true if the key is not set or matches the given key.
|
||||
func (channel *Channel) CheckKey(key string) bool {
|
||||
chkey := channel.Key()
|
||||
if chkey == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(key), []byte(chkey)) == 1
|
||||
return chkey == "" || utils.SecretTokensMatch(chkey, key)
|
||||
}
|
||||
|
||||
func (channel *Channel) IsEmpty() bool {
|
||||
@ -462,6 +462,12 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
|
||||
if givenMode != 0 {
|
||||
rb.Add(nil, client.server.name, "MODE", chname, modestr, nick)
|
||||
}
|
||||
|
||||
channel.history.Add(history.Item{
|
||||
Type: history.Join,
|
||||
Nick: nickmask,
|
||||
AccountName: accountName,
|
||||
})
|
||||
}
|
||||
|
||||
// Part parts the given client from this channel, with the given message.
|
||||
@ -480,9 +486,126 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
|
||||
}
|
||||
rb.Add(nil, nickmask, "PART", chname, message)
|
||||
|
||||
channel.history.Add(history.Item{
|
||||
Type: history.Part,
|
||||
Nick: nickmask,
|
||||
AccountName: client.AccountName(),
|
||||
Message: utils.MakeSplitMessage(message, true),
|
||||
})
|
||||
|
||||
client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", client.nick, chname))
|
||||
}
|
||||
|
||||
// Resume is called after a successful global resume to:
|
||||
// 1. Replace the old client with the new in the channel's data structures
|
||||
// 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(newClient, oldClient *Client, timestamp time.Time) {
|
||||
now := time.Now()
|
||||
channel.resumeAndAnnounce(newClient, oldClient)
|
||||
if !timestamp.IsZero() {
|
||||
channel.replayHistory(newClient, timestamp, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) {
|
||||
var oldModeSet *modes.ModeSet
|
||||
|
||||
func() {
|
||||
channel.joinPartMutex.Lock()
|
||||
defer channel.joinPartMutex.Unlock()
|
||||
|
||||
defer channel.regenerateMembersCache()
|
||||
|
||||
channel.stateMutex.Lock()
|
||||
defer channel.stateMutex.Unlock()
|
||||
|
||||
newClient.channels[channel] = true
|
||||
oldModeSet = channel.members[oldClient]
|
||||
if oldModeSet == nil {
|
||||
oldModeSet = modes.NewModeSet()
|
||||
}
|
||||
channel.members.Remove(oldClient)
|
||||
channel.members[newClient] = oldModeSet
|
||||
}()
|
||||
|
||||
// construct fake modestring if necessary
|
||||
oldModes := oldModeSet.String()
|
||||
if 0 < len(oldModes) {
|
||||
oldModes = "+" + oldModes
|
||||
}
|
||||
|
||||
// send join for old clients
|
||||
nick := newClient.Nick()
|
||||
nickMask := newClient.NickMaskString()
|
||||
accountName := newClient.AccountName()
|
||||
realName := newClient.Realname()
|
||||
for _, member := range channel.Members() {
|
||||
if member.capabilities.Has(caps.Resume) {
|
||||
continue
|
||||
}
|
||||
|
||||
if member.capabilities.Has(caps.ExtendedJoin) {
|
||||
member.Send(nil, nickMask, "JOIN", channel.name, accountName, realName)
|
||||
} else {
|
||||
member.Send(nil, nickMask, "JOIN", channel.name)
|
||||
}
|
||||
|
||||
if 0 < len(oldModes) {
|
||||
member.Send(nil, channel.server.name, "MODE", channel.name, oldModes, nick)
|
||||
}
|
||||
}
|
||||
|
||||
rb := NewResponseBuffer(newClient)
|
||||
// use blocking i/o to synchronize with the later history replay
|
||||
rb.SetBlocking(true)
|
||||
if newClient.capabilities.Has(caps.ExtendedJoin) {
|
||||
rb.Add(nil, nickMask, "JOIN", channel.name, accountName, realName)
|
||||
} else {
|
||||
rb.Add(nil, nickMask, "JOIN", channel.name)
|
||||
}
|
||||
channel.SendTopic(newClient, rb)
|
||||
channel.Names(newClient, rb)
|
||||
if 0 < len(oldModes) {
|
||||
rb.Add(nil, newClient.server.name, "MODE", channel.name, oldModes, nick)
|
||||
}
|
||||
rb.Send()
|
||||
}
|
||||
|
||||
func (channel *Channel) replayHistory(newClient *Client, after time.Time, before time.Time) {
|
||||
chname := channel.Name()
|
||||
extendedJoin := newClient.capabilities.Has(caps.ExtendedJoin)
|
||||
|
||||
items, complete := channel.history.Between(after, before)
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case history.Privmsg:
|
||||
newClient.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, "PRIVMSG", chname, item.Message)
|
||||
case history.Notice:
|
||||
newClient.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, "NOTICE", chname, item.Message)
|
||||
case history.Join:
|
||||
if extendedJoin {
|
||||
newClient.sendInternal(true, item.Time, nil, item.Nick, "JOIN", chname, item.AccountName, "")
|
||||
} else {
|
||||
newClient.sendInternal(true, item.Time, nil, item.Nick, "JOIN", chname)
|
||||
}
|
||||
case history.Quit:
|
||||
// XXX: send QUIT as PART to avoid having to correctly deduplicate and synchronize
|
||||
// QUIT messages across channels
|
||||
fallthrough
|
||||
case history.Part:
|
||||
newClient.sendInternal(true, item.Time, nil, item.Nick, "PART", chname, item.Message.Original)
|
||||
case history.Kick:
|
||||
newClient.sendInternal(true, item.Time, nil, item.Nick, "KICK", chname, item.Msgid, item.Message.Original)
|
||||
}
|
||||
}
|
||||
|
||||
if !complete && !newClient.resumeDetails.HistoryIncomplete {
|
||||
// warn here if we didn't warn already
|
||||
newClient.sendInternal(true, time.Time{}, nil, "HistServ", "NOTICE", chname, newClient.t("Some additional message history may have been lost"))
|
||||
}
|
||||
}
|
||||
|
||||
// SendTopic sends the channel topic to the given client.
|
||||
func (channel *Channel) SendTopic(client *Client, rb *ResponseBuffer) {
|
||||
if !channel.hasClient(client) {
|
||||
@ -622,16 +745,16 @@ func (channel *Channel) sendMessage(msgid, cmd string, requiredCaps []caps.Capab
|
||||
}
|
||||
|
||||
// SplitPrivMsg sends a private message to everyone in this channel.
|
||||
func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) {
|
||||
channel.sendSplitMessage(msgid, "PRIVMSG", minPrefix, clientOnlyTags, client, &message, rb)
|
||||
func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
|
||||
channel.sendSplitMessage(msgid, "PRIVMSG", history.Privmsg, minPrefix, clientOnlyTags, client, &message, rb)
|
||||
}
|
||||
|
||||
// SplitNotice sends a private message to everyone in this channel.
|
||||
func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) {
|
||||
channel.sendSplitMessage(msgid, "NOTICE", minPrefix, clientOnlyTags, client, &message, rb)
|
||||
func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
|
||||
channel.sendSplitMessage(msgid, "NOTICE", history.Notice, minPrefix, clientOnlyTags, client, &message, rb)
|
||||
}
|
||||
|
||||
func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *SplitMessage, rb *ResponseBuffer) {
|
||||
func (channel *Channel) sendSplitMessage(msgid, cmd string, histType history.ItemType, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *utils.SplitMessage, rb *ResponseBuffer) {
|
||||
if !channel.CanSpeak(client) {
|
||||
rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel"))
|
||||
return
|
||||
@ -654,6 +777,10 @@ func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mod
|
||||
rb.AddSplitMessageFromClient(msgid, client, tagsToUse, cmd, channel.name, *message)
|
||||
}
|
||||
}
|
||||
|
||||
nickmask := client.NickMaskString()
|
||||
account := client.AccountName()
|
||||
|
||||
for _, member := range channel.Members() {
|
||||
if minPrefix != nil && !channel.ClientIsAtLeast(member, minPrefixMode) {
|
||||
// STATUSMSG
|
||||
@ -668,12 +795,21 @@ func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mod
|
||||
tagsToUse = clientOnlyTags
|
||||
}
|
||||
|
||||
// TODO(slingamn) evaluate an optimization where we reuse `nickmask` and `account`
|
||||
if message == nil {
|
||||
member.SendFromClient(msgid, client, tagsToUse, cmd, channel.name)
|
||||
} else {
|
||||
member.SendSplitMsgFromClient(msgid, client, tagsToUse, cmd, channel.name, *message)
|
||||
}
|
||||
}
|
||||
|
||||
channel.history.Add(history.Item{
|
||||
Type: histType,
|
||||
Msgid: msgid,
|
||||
Message: *message,
|
||||
Nick: nickmask,
|
||||
AccountName: account,
|
||||
})
|
||||
}
|
||||
|
||||
func (channel *Channel) applyModeToMember(client *Client, mode modes.Mode, op modes.ModeOp, nick string, rb *ResponseBuffer) (result *modes.ModeChange) {
|
||||
@ -806,6 +942,14 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
|
||||
member.Send(nil, clientMask, "KICK", channel.name, targetNick, comment)
|
||||
}
|
||||
|
||||
channel.history.Add(history.Item{
|
||||
Type: history.Kick,
|
||||
Nick: clientMask,
|
||||
Message: utils.MakeSplitMessage(comment, true),
|
||||
AccountName: target.AccountName(),
|
||||
Msgid: targetNick, // XXX abuse this field
|
||||
})
|
||||
|
||||
channel.Quit(target)
|
||||
}
|
||||
|
||||
|
402
irc/client.go
402
irc/client.go
@ -19,6 +19,7 @@ import (
|
||||
"github.com/goshuirc/irc-go/ircmsg"
|
||||
ident "github.com/oragono/go-ident"
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
"github.com/oragono/oragono/irc/history"
|
||||
"github.com/oragono/oragono/irc/modes"
|
||||
"github.com/oragono/oragono/irc/sno"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
@ -26,13 +27,28 @@ import (
|
||||
|
||||
const (
|
||||
// IdentTimeoutSeconds is how many seconds before our ident (username) check times out.
|
||||
IdentTimeoutSeconds = 1.5
|
||||
IdentTimeoutSeconds = 1.5
|
||||
IRCv3TimestampFormat = "2006-01-02T15:04:05.999Z"
|
||||
)
|
||||
|
||||
var (
|
||||
LoopbackIP = net.ParseIP("127.0.0.1")
|
||||
)
|
||||
|
||||
// ResumeDetails is a place to stash data at various stages of
|
||||
// the resume process: when handling the RESUME command itself,
|
||||
// when completing the registration, and when rejoining channels.
|
||||
type ResumeDetails struct {
|
||||
OldClient *Client
|
||||
OldNick string
|
||||
OldNickMask string
|
||||
PresentedToken string
|
||||
Timestamp time.Time
|
||||
ResumedAt time.Time
|
||||
Channels []string
|
||||
HistoryIncomplete bool
|
||||
}
|
||||
|
||||
// Client is an IRC client.
|
||||
type Client struct {
|
||||
account string
|
||||
@ -71,6 +87,7 @@ type Client struct {
|
||||
realname string
|
||||
registered bool
|
||||
resumeDetails *ResumeDetails
|
||||
resumeToken string
|
||||
saslInProgress bool
|
||||
saslMechanism string
|
||||
saslValue string
|
||||
@ -79,6 +96,7 @@ type Client struct {
|
||||
stateMutex sync.RWMutex // tier 1
|
||||
username string
|
||||
vhost string
|
||||
history *history.Buffer
|
||||
}
|
||||
|
||||
// NewClient sets up a new client and starts its goroutine.
|
||||
@ -101,6 +119,7 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) {
|
||||
nick: "*", // * is used until actual nick is given
|
||||
nickCasefolded: "*",
|
||||
nickMaskString: "*", // * is used until actual nick is given
|
||||
history: history.NewHistoryBuffer(config.History.ClientLength),
|
||||
}
|
||||
client.languages = server.languages.Default()
|
||||
|
||||
@ -350,124 +369,199 @@ func (client *Client) TryResume() {
|
||||
}
|
||||
|
||||
server := client.server
|
||||
|
||||
// just grab these mutexes for safety. later we can work out whether we can grab+release them earlier
|
||||
server.clients.Lock()
|
||||
defer server.clients.Unlock()
|
||||
server.channels.Lock()
|
||||
defer server.channels.Unlock()
|
||||
config := server.Config()
|
||||
|
||||
oldnick := client.resumeDetails.OldNick
|
||||
timestamp := client.resumeDetails.Timestamp
|
||||
var timestampString string
|
||||
if timestamp != nil {
|
||||
timestampString = timestamp.UTC().Format("2006-01-02T15:04:05.999Z")
|
||||
if !timestamp.IsZero() {
|
||||
timestampString = timestamp.UTC().Format(IRCv3TimestampFormat)
|
||||
}
|
||||
|
||||
// can't use server.clients.Get since we hold server.clients' tier 1 mutex
|
||||
casefoldedName, err := CasefoldName(oldnick)
|
||||
if err != nil {
|
||||
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found"))
|
||||
return
|
||||
}
|
||||
|
||||
oldClient := server.clients.byNick[casefoldedName]
|
||||
oldClient := server.clients.Get(oldnick)
|
||||
if oldClient == nil {
|
||||
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found"))
|
||||
client.Send(nil, server.name, "RESUME", "ERR", oldnick, client.t("Cannot resume connection, old client not found"))
|
||||
client.resumeDetails = nil
|
||||
return
|
||||
}
|
||||
oldNick := oldClient.Nick()
|
||||
oldNickmask := oldClient.NickMaskString()
|
||||
|
||||
resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS))
|
||||
if !resumeAllowed {
|
||||
client.Send(nil, server.name, "RESUME", "ERR", oldnick, client.t("Cannot resume connection, old and new clients must have TLS"))
|
||||
client.resumeDetails = nil
|
||||
return
|
||||
}
|
||||
|
||||
oldAccountName := oldClient.Account()
|
||||
newAccountName := client.Account()
|
||||
|
||||
if oldAccountName == "" || newAccountName == "" || oldAccountName != newAccountName {
|
||||
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must be logged into the same account"))
|
||||
oldResumeToken := oldClient.ResumeToken()
|
||||
if oldResumeToken == "" || !utils.SecretTokensMatch(oldResumeToken, client.resumeDetails.PresentedToken) {
|
||||
client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection, invalid resume token"))
|
||||
client.resumeDetails = nil
|
||||
return
|
||||
}
|
||||
|
||||
if !oldClient.HasMode(modes.TLS) || !client.HasMode(modes.TLS) {
|
||||
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must have TLS"))
|
||||
err := server.clients.Resume(client, oldClient)
|
||||
if err != nil {
|
||||
client.resumeDetails = nil
|
||||
client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection"))
|
||||
return
|
||||
}
|
||||
|
||||
// unmark the new client's nick as being occupied
|
||||
server.clients.removeInternal(client)
|
||||
// this is a bit racey
|
||||
client.resumeDetails.ResumedAt = time.Now()
|
||||
|
||||
// send RESUMED to the reconnecting client
|
||||
if timestamp == nil {
|
||||
client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname())
|
||||
} else {
|
||||
client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString)
|
||||
client.nickTimer.Touch()
|
||||
|
||||
// resume successful, proceed to copy client state (nickname, flags, etc.)
|
||||
// after this, the server thinks that `newClient` owns the nickname
|
||||
|
||||
client.resumeDetails.OldClient = oldClient
|
||||
|
||||
// transfer monitor stuff
|
||||
server.monitorManager.Resume(client, oldClient)
|
||||
|
||||
// record the names, not the pointers, of the channels,
|
||||
// to avoid dumb annoying race conditions
|
||||
channels := oldClient.Channels()
|
||||
client.resumeDetails.Channels = make([]string, len(channels))
|
||||
for i, channel := range channels {
|
||||
client.resumeDetails.Channels[i] = channel.Name()
|
||||
}
|
||||
|
||||
// send QUIT/RESUMED to friends
|
||||
for friend := range oldClient.Friends() {
|
||||
username := client.Username()
|
||||
hostname := client.Hostname()
|
||||
|
||||
friends := make(ClientSet)
|
||||
oldestLostMessage := time.Now()
|
||||
|
||||
// work out how much time, if any, is not covered by history buffers
|
||||
for _, channel := range channels {
|
||||
for _, member := range channel.Members() {
|
||||
friends.Add(member)
|
||||
lastDiscarded := channel.history.LastDiscarded()
|
||||
if lastDiscarded.Before(oldestLostMessage) {
|
||||
oldestLostMessage = lastDiscarded
|
||||
}
|
||||
}
|
||||
}
|
||||
personalHistory := oldClient.history.All()
|
||||
lastDiscarded := oldClient.history.LastDiscarded()
|
||||
if lastDiscarded.Before(oldestLostMessage) {
|
||||
oldestLostMessage = lastDiscarded
|
||||
}
|
||||
for _, item := range personalHistory {
|
||||
if item.Type == history.Privmsg || item.Type == history.Notice {
|
||||
sender := server.clients.Get(item.Nick)
|
||||
if sender != nil {
|
||||
friends.Add(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gap := lastDiscarded.Sub(timestamp)
|
||||
client.resumeDetails.HistoryIncomplete = gap > 0
|
||||
gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion
|
||||
|
||||
// send quit/resume messages to friends
|
||||
for friend := range friends {
|
||||
if friend.capabilities.Has(caps.Resume) {
|
||||
if timestamp == nil {
|
||||
friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname())
|
||||
if timestamp.IsZero() {
|
||||
friend.Send(nil, oldNickmask, "RESUMED", username, hostname)
|
||||
} else {
|
||||
friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString)
|
||||
friend.Send(nil, oldNickmask, "RESUMED", username, hostname, timestampString)
|
||||
}
|
||||
} else {
|
||||
friend.Send(nil, oldClient.NickMaskString(), "QUIT", friend.t("Client reconnected"))
|
||||
if client.resumeDetails.HistoryIncomplete {
|
||||
friend.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of history lost)"), gapSeconds))
|
||||
} else {
|
||||
friend.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply old client's details to new client
|
||||
client.nick = oldClient.nick
|
||||
client.updateNickMaskNoMutex()
|
||||
if client.resumeDetails.HistoryIncomplete {
|
||||
client.Send(nil, "RESUME", "WARN", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds))
|
||||
}
|
||||
|
||||
rejoinChannel := func(channel *Channel) {
|
||||
channel.joinPartMutex.Lock()
|
||||
defer channel.joinPartMutex.Unlock()
|
||||
client.Send(nil, "RESUME", "SUCCESS", oldNick)
|
||||
|
||||
channel.stateMutex.Lock()
|
||||
client.channels[channel] = true
|
||||
client.resumeDetails.SendFakeJoinsFor = append(client.resumeDetails.SendFakeJoinsFor, channel.name)
|
||||
// after we send the rest of the registration burst, we'll try rejoining channels
|
||||
}
|
||||
|
||||
oldModeSet := channel.members[oldClient]
|
||||
channel.members.Remove(oldClient)
|
||||
channel.members[client] = oldModeSet
|
||||
channel.stateMutex.Unlock()
|
||||
func (client *Client) tryResumeChannels() {
|
||||
details := client.resumeDetails
|
||||
if details == nil {
|
||||
return
|
||||
}
|
||||
|
||||
channel.regenerateMembersCache()
|
||||
|
||||
// construct fake modestring if necessary
|
||||
oldModes := oldModeSet.String()
|
||||
var params []string
|
||||
if 0 < len(oldModes) {
|
||||
params = []string{channel.name, "+" + oldModes}
|
||||
for range oldModes {
|
||||
params = append(params, client.nick)
|
||||
}
|
||||
channels := make([]*Channel, len(details.Channels))
|
||||
for _, name := range details.Channels {
|
||||
channel := client.server.channels.Get(name)
|
||||
if channel == nil {
|
||||
continue
|
||||
}
|
||||
channel.Resume(client, details.OldClient, details.Timestamp)
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
|
||||
// send join for old clients
|
||||
for member := range channel.members {
|
||||
if member.capabilities.Has(caps.Resume) {
|
||||
// replay direct PRIVSMG history
|
||||
if !details.Timestamp.IsZero() {
|
||||
now := time.Now()
|
||||
nick := client.Nick()
|
||||
items, complete := client.history.Between(details.Timestamp, now)
|
||||
for _, item := range items {
|
||||
var command string
|
||||
switch item.Type {
|
||||
case history.Privmsg:
|
||||
command = "PRIVMSG"
|
||||
case history.Notice:
|
||||
command = "NOTICE"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if member.capabilities.Has(caps.ExtendedJoin) {
|
||||
member.Send(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname)
|
||||
} else {
|
||||
member.Send(nil, client.nickMaskString, "JOIN", channel.name)
|
||||
}
|
||||
|
||||
// send fake modestring if necessary
|
||||
if 0 < len(oldModes) {
|
||||
member.Send(nil, server.name, "MODE", params...)
|
||||
}
|
||||
client.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, command, nick, item.Message)
|
||||
}
|
||||
if !complete {
|
||||
client.Send(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost"))
|
||||
}
|
||||
}
|
||||
|
||||
for channel := range oldClient.channels {
|
||||
rejoinChannel(channel)
|
||||
}
|
||||
details.OldClient.destroy(true)
|
||||
}
|
||||
|
||||
server.clients.byNick[oldnick] = client
|
||||
// copy applicable state from oldClient to client as part of a resume
|
||||
func (client *Client) copyResumeData(oldClient *Client) {
|
||||
oldClient.stateMutex.RLock()
|
||||
flags := oldClient.flags
|
||||
history := oldClient.history
|
||||
nick := oldClient.nick
|
||||
nickCasefolded := oldClient.nickCasefolded
|
||||
vhost := oldClient.vhost
|
||||
account := oldClient.account
|
||||
accountName := oldClient.accountName
|
||||
oldClient.stateMutex.RUnlock()
|
||||
|
||||
oldClient.destroy(true)
|
||||
// copy all flags, *except* TLS (in the case that the admins enabled
|
||||
// resume over plaintext)
|
||||
hasTLS := client.flags.HasMode(modes.TLS)
|
||||
temp := modes.NewModeSet()
|
||||
temp.Copy(flags)
|
||||
temp.SetMode(modes.TLS, hasTLS)
|
||||
client.flags.Copy(temp)
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
// reuse the old client's history buffer
|
||||
client.history = history
|
||||
// copy other data
|
||||
client.nick = nick
|
||||
client.nickCasefolded = nickCasefolded
|
||||
client.vhost = vhost
|
||||
client.account = account
|
||||
client.accountName = accountName
|
||||
client.updateNickMaskNoMutex()
|
||||
}
|
||||
|
||||
// IdleTime returns how long this client's been idle.
|
||||
@ -501,6 +595,26 @@ func (client *Client) HasUsername() bool {
|
||||
return client.username != "" && client.username != "*"
|
||||
}
|
||||
|
||||
func (client *Client) SetNames(username, realname string) error {
|
||||
_, err := CasefoldName(username)
|
||||
if err != nil {
|
||||
return errInvalidUsername
|
||||
}
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
if client.username == "" {
|
||||
client.username = "~" + username
|
||||
}
|
||||
|
||||
if client.realname == "" {
|
||||
client.realname = realname
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRoleCapabs returns true if client has the given (role) capabilities.
|
||||
func (client *Client) HasRoleCapabs(capabs ...string) bool {
|
||||
oper := client.Oper()
|
||||
@ -561,7 +675,7 @@ func (client *Client) Friends(capabs ...caps.Capability) ClientSet {
|
||||
func (client *Client) sendChghost(oldNickMask string, vhost string) {
|
||||
username := client.Username()
|
||||
for fClient := range client.Friends(caps.ChgHost) {
|
||||
fClient.sendFromClientInternal("", client, oldNickMask, nil, "CHGHOST", username, vhost)
|
||||
fClient.sendFromClientInternal(false, time.Time{}, "", oldNickMask, client.AccountName(), nil, "CHGHOST", username, vhost)
|
||||
}
|
||||
}
|
||||
|
||||
@ -711,14 +825,14 @@ func (client *Client) Quit(message string) {
|
||||
// destroy gets rid of a client, removes them from server lists etc.
|
||||
func (client *Client) destroy(beingResumed bool) {
|
||||
// allow destroy() to execute at most once
|
||||
if !beingResumed {
|
||||
client.stateMutex.Lock()
|
||||
}
|
||||
client.stateMutex.Lock()
|
||||
isDestroyed := client.isDestroyed
|
||||
client.isDestroyed = true
|
||||
if !beingResumed {
|
||||
client.stateMutex.Unlock()
|
||||
}
|
||||
quitMessage := client.quitMessage
|
||||
nickMaskString := client.nickMaskString
|
||||
accountName := client.accountName
|
||||
client.stateMutex.Unlock()
|
||||
|
||||
if isDestroyed {
|
||||
return
|
||||
}
|
||||
@ -758,6 +872,12 @@ func (client *Client) destroy(beingResumed bool) {
|
||||
for _, channel := range client.Channels() {
|
||||
if !beingResumed {
|
||||
channel.Quit(client)
|
||||
channel.history.Add(history.Item{
|
||||
Type: history.Quit,
|
||||
Nick: nickMaskString,
|
||||
AccountName: accountName,
|
||||
Message: utils.MakeSplitMessage(quitMessage, true),
|
||||
})
|
||||
}
|
||||
for _, member := range channel.Members() {
|
||||
friends.Add(member)
|
||||
@ -791,10 +911,10 @@ func (client *Client) destroy(beingResumed bool) {
|
||||
}
|
||||
|
||||
for friend := range friends {
|
||||
if client.quitMessage == "" {
|
||||
client.quitMessage = "Exited"
|
||||
if quitMessage == "" {
|
||||
quitMessage = "Exited"
|
||||
}
|
||||
friend.Send(nil, client.nickMaskString, "QUIT", client.quitMessage)
|
||||
friend.Send(nil, client.nickMaskString, "QUIT", quitMessage)
|
||||
}
|
||||
}
|
||||
if !client.exitedSnomaskSent {
|
||||
@ -808,43 +928,50 @@ func (client *Client) destroy(beingResumed bool) {
|
||||
|
||||
// SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client.
|
||||
// Adds account-tag to the line as well.
|
||||
func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command, target string, message SplitMessage) {
|
||||
if client.capabilities.Has(caps.MaxLine) {
|
||||
client.SendFromClient(msgid, from, tags, command, target, message.ForMaxLine)
|
||||
func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags Tags, command, target string, message utils.SplitMessage) {
|
||||
client.sendSplitMsgFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, target, message)
|
||||
}
|
||||
|
||||
func (client *Client) sendSplitMsgFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags Tags, command, target string, message utils.SplitMessage) {
|
||||
if client.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
|
||||
client.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, target, message.Original)
|
||||
} else {
|
||||
for _, str := range message.For512 {
|
||||
client.SendFromClient(msgid, from, tags, command, target, str)
|
||||
for _, str := range message.Wrapped {
|
||||
client.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, target, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendFromClient sends an IRC line coming from a specific client.
|
||||
// Adds account-tag to the line as well.
|
||||
func (client *Client) SendFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, params ...string) error {
|
||||
return client.sendFromClientInternal(msgid, from, from.NickMaskString(), tags, command, params...)
|
||||
func (client *Client) SendFromClient(msgid string, from *Client, tags Tags, command string, params ...string) error {
|
||||
return client.sendFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, params...)
|
||||
}
|
||||
|
||||
// helper to add a tag to `tags` (or create a new tag set if the current one is nil)
|
||||
func ensureTag(tags Tags, tagName, tagValue string) (result Tags) {
|
||||
if tags == nil {
|
||||
result = ircmsg.MakeTags(tagName, tagValue)
|
||||
} else {
|
||||
result = tags
|
||||
(*tags)[tagName] = ircmsg.MakeTagValue(tagValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// XXX this is a hack where we allow overriding the client's nickmask
|
||||
// this is to support CHGHOST, which requires that we send the *original* nickmask with the response
|
||||
func (client *Client) sendFromClientInternal(msgid string, from *Client, nickmask string, tags *map[string]ircmsg.TagValue, command string, params ...string) error {
|
||||
func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags Tags, command string, params ...string) error {
|
||||
// attach account-tag
|
||||
if client.capabilities.Has(caps.AccountTag) && from.LoggedIntoAccount() {
|
||||
if tags == nil {
|
||||
tags = ircmsg.MakeTags("account", from.AccountName())
|
||||
} else {
|
||||
(*tags)["account"] = ircmsg.MakeTagValue(from.AccountName())
|
||||
}
|
||||
if client.capabilities.Has(caps.AccountTag) && accountName != "*" {
|
||||
tags = ensureTag(tags, "account", accountName)
|
||||
}
|
||||
// attach message-id
|
||||
if len(msgid) > 0 && client.capabilities.Has(caps.MessageTags) {
|
||||
if tags == nil {
|
||||
tags = ircmsg.MakeTags("draft/msgid", msgid)
|
||||
} else {
|
||||
(*tags)["draft/msgid"] = ircmsg.MakeTagValue(msgid)
|
||||
}
|
||||
tags = ensureTag(tags, "draft/msgid", msgid)
|
||||
}
|
||||
|
||||
return client.Send(tags, nickmask, command, params...)
|
||||
return client.sendInternal(blocking, serverTime, tags, nickmask, command, params...)
|
||||
}
|
||||
|
||||
var (
|
||||
@ -861,7 +988,7 @@ var (
|
||||
)
|
||||
|
||||
// SendRawMessage sends a raw message to the client.
|
||||
func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
|
||||
func (client *Client) SendRawMessage(message ircmsg.IrcMessage, blocking bool) error {
|
||||
// use dumb hack to force the last param to be a trailing param if required
|
||||
var usedTrailingHack bool
|
||||
if commandsThatMustUseTrailing[strings.ToUpper(message.Command)] && len(message.Params) > 0 {
|
||||
@ -883,7 +1010,11 @@ func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
|
||||
message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending")
|
||||
line, _ := message.LineBytes()
|
||||
|
||||
client.socket.Write(line)
|
||||
if blocking {
|
||||
client.socket.BlockingWrite(line)
|
||||
} else {
|
||||
client.socket.Write(line)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@ -898,36 +1029,40 @@ func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
|
||||
client.server.logger.Debug("useroutput", client.nick, " ->", logline)
|
||||
}
|
||||
|
||||
client.socket.Write(line)
|
||||
|
||||
return nil
|
||||
if blocking {
|
||||
return client.socket.BlockingWrite(line)
|
||||
} else {
|
||||
return client.socket.Write(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends an IRC line to the client.
|
||||
func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error {
|
||||
// attach server-time
|
||||
func (client *Client) sendInternal(blocking bool, serverTime time.Time, tags Tags, prefix string, command string, params ...string) error {
|
||||
// attach server time
|
||||
if client.capabilities.Has(caps.ServerTime) {
|
||||
t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
|
||||
if tags == nil {
|
||||
tags = ircmsg.MakeTags("time", t)
|
||||
} else {
|
||||
(*tags)["time"] = ircmsg.MakeTagValue(t)
|
||||
if serverTime.IsZero() {
|
||||
serverTime = time.Now()
|
||||
}
|
||||
tags = ensureTag(tags, "time", serverTime.UTC().Format(IRCv3TimestampFormat))
|
||||
}
|
||||
|
||||
// send out the message
|
||||
message := ircmsg.MakeMessage(tags, prefix, command, params...)
|
||||
client.SendRawMessage(message)
|
||||
client.SendRawMessage(message, blocking)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends an IRC line to the client.
|
||||
func (client *Client) Send(tags Tags, prefix string, command string, params ...string) error {
|
||||
return client.sendInternal(false, time.Time{}, tags, prefix, command, params...)
|
||||
}
|
||||
|
||||
// Notice sends the client a notice from the server.
|
||||
func (client *Client) Notice(text string) {
|
||||
limit := 400
|
||||
if client.capabilities.Has(caps.MaxLine) {
|
||||
limit = client.server.Limits().LineLen.Rest - 110
|
||||
}
|
||||
lines := wordWrap(text, limit)
|
||||
lines := utils.WordWrap(text, limit)
|
||||
|
||||
// force blank lines to be sent if we receive them
|
||||
if len(lines) == 0 {
|
||||
@ -950,3 +1085,20 @@ func (client *Client) removeChannel(channel *Channel) {
|
||||
delete(client.channels, channel)
|
||||
client.stateMutex.Unlock()
|
||||
}
|
||||
|
||||
// Ensures the client has a cryptographically secure resume token, and returns
|
||||
// its value. An error is returned if a token was previously assigned.
|
||||
func (client *Client) generateResumeToken() (token string, err error) {
|
||||
newToken := utils.GenerateSecretToken()
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
if client.resumeToken == "" {
|
||||
client.resumeToken = newToken
|
||||
} else {
|
||||
err = errResumeTokenAlreadySet
|
||||
}
|
||||
|
||||
return client.resumeToken, err
|
||||
}
|
||||
|
@ -63,17 +63,17 @@ func (clients *ClientManager) Get(nick string) *Client {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (clients *ClientManager) removeInternal(client *Client) (removed bool) {
|
||||
func (clients *ClientManager) removeInternal(client *Client) (err error) {
|
||||
// requires holding the writable Lock()
|
||||
oldcfnick := client.NickCasefolded()
|
||||
currentEntry, present := clients.byNick[oldcfnick]
|
||||
if present {
|
||||
if currentEntry == client {
|
||||
delete(clients.byNick, oldcfnick)
|
||||
removed = true
|
||||
} else {
|
||||
// this shouldn't happen, but we can ignore it
|
||||
client.server.logger.Warning("internal", fmt.Sprintf("clients for nick %s out of sync", oldcfnick))
|
||||
err = errNickMissing
|
||||
}
|
||||
}
|
||||
return
|
||||
@ -87,7 +87,28 @@ func (clients *ClientManager) Remove(client *Client) error {
|
||||
if !client.HasNick() {
|
||||
return errNickMissing
|
||||
}
|
||||
clients.removeInternal(client)
|
||||
return clients.removeInternal(client)
|
||||
}
|
||||
|
||||
// Resume atomically replaces `oldClient` with `newClient`, updating
|
||||
// newClient's data to match. It is the caller's responsibility first
|
||||
// to verify that the resume is allowed, and then later to call oldClient.destroy().
|
||||
func (clients *ClientManager) Resume(newClient, oldClient *Client) (err error) {
|
||||
clients.Lock()
|
||||
defer clients.Unlock()
|
||||
|
||||
// atomically grant the new client the old nick
|
||||
err = clients.removeInternal(oldClient)
|
||||
if err != nil {
|
||||
// oldClient no longer owns its nick, fail out
|
||||
return err
|
||||
}
|
||||
// nick has been reclaimed, grant it to the new client
|
||||
clients.removeInternal(newClient)
|
||||
clients.byNick[oldClient.NickCasefolded()] = newClient
|
||||
|
||||
newClient.copyResumeData(oldClient)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -222,7 +222,7 @@ func init() {
|
||||
"RESUME": {
|
||||
handler: resumeHandler,
|
||||
usablePreReg: true,
|
||||
minParams: 1,
|
||||
minParams: 2,
|
||||
},
|
||||
"SAJOIN": {
|
||||
handler: sajoinHandler,
|
||||
|
@ -208,23 +208,24 @@ type Config struct {
|
||||
}
|
||||
|
||||
Server struct {
|
||||
Password string
|
||||
passwordBytes []byte
|
||||
Name string
|
||||
nameCasefolded string
|
||||
Listen []string
|
||||
UnixBindMode os.FileMode `yaml:"unix-bind-mode"`
|
||||
TLSListeners map[string]*TLSListenConfig `yaml:"tls-listeners"`
|
||||
STS STSConfig
|
||||
CheckIdent bool `yaml:"check-ident"`
|
||||
MOTD string
|
||||
MOTDFormatting bool `yaml:"motd-formatting"`
|
||||
ProxyAllowedFrom []string `yaml:"proxy-allowed-from"`
|
||||
WebIRC []webircConfig `yaml:"webirc"`
|
||||
MaxSendQString string `yaml:"max-sendq"`
|
||||
MaxSendQBytes int
|
||||
ConnectionLimiter connection_limits.LimiterConfig `yaml:"connection-limits"`
|
||||
ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"`
|
||||
Password string
|
||||
passwordBytes []byte
|
||||
Name string
|
||||
nameCasefolded string
|
||||
Listen []string
|
||||
UnixBindMode os.FileMode `yaml:"unix-bind-mode"`
|
||||
TLSListeners map[string]*TLSListenConfig `yaml:"tls-listeners"`
|
||||
STS STSConfig
|
||||
CheckIdent bool `yaml:"check-ident"`
|
||||
MOTD string
|
||||
MOTDFormatting bool `yaml:"motd-formatting"`
|
||||
ProxyAllowedFrom []string `yaml:"proxy-allowed-from"`
|
||||
WebIRC []webircConfig `yaml:"webirc"`
|
||||
MaxSendQString string `yaml:"max-sendq"`
|
||||
MaxSendQBytes int
|
||||
AllowPlaintextResume bool `yaml:"allow-plaintext-resume"`
|
||||
ConnectionLimiter connection_limits.LimiterConfig `yaml:"connection-limits"`
|
||||
ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"`
|
||||
}
|
||||
|
||||
Languages struct {
|
||||
@ -266,6 +267,12 @@ type Config struct {
|
||||
|
||||
Fakelag FakelagConfig
|
||||
|
||||
History struct {
|
||||
Enabled bool
|
||||
ChannelLength int `yaml:"channel-length"`
|
||||
ClientLength int `yaml:"client-length"`
|
||||
}
|
||||
|
||||
Filename string
|
||||
}
|
||||
|
||||
@ -712,5 +719,13 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||
config.Accounts.Registration.BcryptCost = passwd.DefaultCost
|
||||
}
|
||||
|
||||
// in the current implementation, we disable history by creating a history buffer
|
||||
// with zero capacity. but the `enabled` config option MUST be respected regardless
|
||||
// of this detail
|
||||
if !config.History.Enabled {
|
||||
config.History.ChannelLength = 0
|
||||
config.History.ClientLength = 0
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
@ -38,6 +38,8 @@ var (
|
||||
errRenamePrivsNeeded = errors.New("Only chanops can rename channels")
|
||||
errInsufficientPrivs = errors.New("Insufficient privileges")
|
||||
errSaslFail = errors.New("SASL failed")
|
||||
errResumeTokenAlreadySet = errors.New("Client was already assigned a resume token")
|
||||
errInvalidUsername = errors.New("Invalid username")
|
||||
)
|
||||
|
||||
// Socket Errors
|
||||
|
@ -102,6 +102,12 @@ func (client *Client) Realname() string {
|
||||
return client.realname
|
||||
}
|
||||
|
||||
func (client *Client) ResumeToken() string {
|
||||
client.stateMutex.RLock()
|
||||
defer client.stateMutex.RUnlock()
|
||||
return client.resumeToken
|
||||
}
|
||||
|
||||
func (client *Client) Oper() *Oper {
|
||||
client.stateMutex.RLock()
|
||||
defer client.stateMutex.RUnlock()
|
||||
|
@ -26,6 +26,7 @@ import (
|
||||
"github.com/goshuirc/irc-go/ircmsg"
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
"github.com/oragono/oragono/irc/custime"
|
||||
"github.com/oragono/oragono/irc/history"
|
||||
"github.com/oragono/oragono/irc/modes"
|
||||
"github.com/oragono/oragono/irc/sno"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
@ -483,6 +484,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
|
||||
client.capabilities.Union(capabilities)
|
||||
rb.Add(nil, server.name, "CAP", client.nick, "ACK", capString)
|
||||
|
||||
// if this is the first time the client is requesting a resume token,
|
||||
// send it to them
|
||||
if capabilities.Has(caps.Resume) {
|
||||
token, err := client.generateResumeToken()
|
||||
if err == nil {
|
||||
rb.Add(nil, server.name, "RESUME", "TOKEN", token)
|
||||
}
|
||||
}
|
||||
|
||||
case "END":
|
||||
if !client.Registered() {
|
||||
client.capState = caps.NegotiatedState
|
||||
@ -1648,7 +1658,7 @@ func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
|
||||
message := msg.Params[1]
|
||||
|
||||
// split privmsg
|
||||
splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine))
|
||||
splitMsg := utils.MakeSplitMessage(message, !client.capabilities.Has(caps.MaxLine))
|
||||
|
||||
for i, targetString := range targets {
|
||||
// max of four targets per privmsg
|
||||
@ -1699,6 +1709,14 @@ func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
|
||||
if client.capabilities.Has(caps.EchoMessage) {
|
||||
rb.AddSplitMessageFromClient(msgid, client, clientOnlyTags, "NOTICE", user.nick, splitMsg)
|
||||
}
|
||||
|
||||
user.history.Add(history.Item{
|
||||
Type: history.Notice,
|
||||
Msgid: msgid,
|
||||
Message: splitMsg,
|
||||
Nick: client.NickMaskString(),
|
||||
AccountName: client.AccountName(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return false
|
||||
@ -1848,7 +1866,7 @@ func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
|
||||
message := msg.Params[1]
|
||||
|
||||
// split privmsg
|
||||
splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine))
|
||||
splitMsg := utils.MakeSplitMessage(message, !client.capabilities.Has(caps.MaxLine))
|
||||
|
||||
for i, targetString := range targets {
|
||||
// max of four targets per privmsg
|
||||
@ -1905,6 +1923,14 @@ func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
|
||||
//TODO(dan): possibly implement cooldown of away notifications to users
|
||||
rb.Add(nil, server.name, RPL_AWAY, user.nick, user.awayMessage)
|
||||
}
|
||||
|
||||
user.history.Add(history.Item{
|
||||
Type: history.Privmsg,
|
||||
Msgid: msgid,
|
||||
Message: splitMsg,
|
||||
Nick: client.NickMaskString(),
|
||||
AccountName: client.AccountName(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return false
|
||||
@ -2018,33 +2044,30 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
|
||||
return false
|
||||
}
|
||||
|
||||
// RESUME <oldnick> [timestamp]
|
||||
// RESUME <oldnick> <token> [timestamp]
|
||||
func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
|
||||
oldnick := msg.Params[0]
|
||||
|
||||
if strings.Contains(oldnick, " ") {
|
||||
rb.Add(nil, server.name, ERR_CANNOT_RESUME, "*", client.t("Cannot resume connection, old nickname contains spaces"))
|
||||
return false
|
||||
}
|
||||
token := msg.Params[1]
|
||||
|
||||
if client.Registered() {
|
||||
rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, connection registration has already been completed"))
|
||||
return false
|
||||
}
|
||||
|
||||
var timestamp *time.Time
|
||||
if 1 < len(msg.Params) {
|
||||
ts, err := time.Parse("2006-01-02T15:04:05.999Z", msg.Params[1])
|
||||
var timestamp time.Time
|
||||
if 2 < len(msg.Params) {
|
||||
ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[2])
|
||||
if err == nil {
|
||||
timestamp = &ts
|
||||
timestamp = ts
|
||||
} else {
|
||||
rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it"))
|
||||
}
|
||||
}
|
||||
|
||||
client.resumeDetails = &ResumeDetails{
|
||||
OldNick: oldnick,
|
||||
Timestamp: timestamp,
|
||||
OldNick: oldnick,
|
||||
Timestamp: timestamp,
|
||||
PresentedToken: token,
|
||||
}
|
||||
|
||||
return false
|
||||
@ -2280,26 +2303,12 @@ func userHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
|
||||
return false
|
||||
}
|
||||
|
||||
if client.username != "" && client.realname != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// confirm that username is valid
|
||||
//
|
||||
_, err := CasefoldName(msg.Params[0])
|
||||
if err != nil {
|
||||
err := client.SetNames(msg.Params[0], msg.Params[3])
|
||||
if err == errInvalidUsername {
|
||||
rb.Add(nil, "", "ERROR", client.t("Malformed username"))
|
||||
return true
|
||||
}
|
||||
|
||||
if !client.HasUsername() {
|
||||
client.username = "~" + msg.Params[0]
|
||||
// don't bother updating nickmask here, it's not valid anyway
|
||||
}
|
||||
if client.realname == "" {
|
||||
client.realname = msg.Params[3]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
249
irc/history/history.go
Normal file
249
irc/history/history.go
Normal file
@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// released under the MIT license
|
||||
|
||||
package history
|
||||
|
||||
import (
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ItemType uint
|
||||
|
||||
const (
|
||||
uninitializedItem ItemType = iota
|
||||
Privmsg
|
||||
Notice
|
||||
Join
|
||||
Part
|
||||
Kick
|
||||
Quit
|
||||
Mode
|
||||
)
|
||||
|
||||
// Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data
|
||||
type Item struct {
|
||||
Type ItemType
|
||||
Time time.Time
|
||||
|
||||
Nick string
|
||||
// this is the uncasefolded account name, if there's no account it should be set to "*"
|
||||
AccountName string
|
||||
Message utils.SplitMessage
|
||||
Msgid string
|
||||
}
|
||||
|
||||
// Buffer is a ring buffer holding message/event history for a channel or user
|
||||
type Buffer struct {
|
||||
sync.RWMutex
|
||||
|
||||
// ring buffer, see irc/whowas.go for conventions
|
||||
buffer []Item
|
||||
start int
|
||||
end int
|
||||
|
||||
lastDiscarded time.Time
|
||||
|
||||
enabled uint32
|
||||
}
|
||||
|
||||
func NewHistoryBuffer(size int) (result *Buffer) {
|
||||
result = new(Buffer)
|
||||
result.Initialize(size)
|
||||
return
|
||||
}
|
||||
|
||||
func (hist *Buffer) Initialize(size int) {
|
||||
hist.buffer = make([]Item, size)
|
||||
hist.start = -1
|
||||
hist.end = -1
|
||||
|
||||
hist.setEnabled(size)
|
||||
}
|
||||
|
||||
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.Time.IsZero() {
|
||||
item.Time = time.Now()
|
||||
}
|
||||
|
||||
list.Lock()
|
||||
defer list.Unlock()
|
||||
|
||||
var pos int
|
||||
if list.start == -1 { // empty
|
||||
pos = 0
|
||||
list.start = 0
|
||||
list.end = 1 % len(list.buffer)
|
||||
} else if list.start != list.end { // partially full
|
||||
pos = list.end
|
||||
list.end = (list.end + 1) % len(list.buffer)
|
||||
} else if list.start == list.end { // full
|
||||
pos = list.end
|
||||
list.end = (list.end + 1) % len(list.buffer)
|
||||
list.start = list.end // advance start as well, overwriting first entry
|
||||
// record the timestamp of the overwritten item
|
||||
if list.lastDiscarded.Before(list.buffer[pos].Time) {
|
||||
list.lastDiscarded = list.buffer[pos].Time
|
||||
}
|
||||
}
|
||||
|
||||
list.buffer[pos] = item
|
||||
}
|
||||
|
||||
// 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) (results []Item, complete bool) {
|
||||
if !list.Enabled() {
|
||||
return
|
||||
}
|
||||
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded)
|
||||
|
||||
if list.start == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
satisfies := func(itime time.Time) bool {
|
||||
return (after.IsZero() || itime.After(after)) && (before.IsZero() || itime.Before(before))
|
||||
}
|
||||
|
||||
// TODO: if we can guarantee that the insertion order is also the monotonic clock order,
|
||||
// then this can do a single allocation and use binary search and 1-2 copy calls
|
||||
|
||||
pos := list.prev(list.end)
|
||||
for {
|
||||
if satisfies(list.buffer[pos].Time) {
|
||||
results = append(results, list.buffer[pos])
|
||||
}
|
||||
if pos == list.start {
|
||||
break
|
||||
}
|
||||
pos = list.prev(pos)
|
||||
}
|
||||
|
||||
// reverse the results
|
||||
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
|
||||
results[i], results[j] = results[j], results[i]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// All returns all available history items as a slice
|
||||
func (list *Buffer) All() (results []Item) {
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
if list.start == -1 {
|
||||
return
|
||||
}
|
||||
results = make([]Item, list.length())
|
||||
if list.start < list.end {
|
||||
copy(results, list.buffer[list.start:list.end])
|
||||
} else {
|
||||
initialSegment := copy(results, list.buffer[list.start:])
|
||||
copy(results[initialSegment:], list.buffer[:list.end])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// LastDiscarded returns the latest time of any entry that was evicted
|
||||
// from the ring buffer.
|
||||
func (list *Buffer) LastDiscarded() time.Time {
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
return list.lastDiscarded
|
||||
}
|
||||
|
||||
func (list *Buffer) prev(index int) int {
|
||||
switch index {
|
||||
case 0:
|
||||
return len(list.buffer) - 1
|
||||
default:
|
||||
return index - 1
|
||||
}
|
||||
}
|
||||
|
||||
// Resize shrinks or expands the buffer
|
||||
func (list *Buffer) Resize(size int) {
|
||||
newbuffer := make([]Item, size)
|
||||
list.Lock()
|
||||
defer list.Unlock()
|
||||
|
||||
list.setEnabled(size)
|
||||
|
||||
if list.start == -1 {
|
||||
// indices are already correct and nothing needs to be copied
|
||||
} else if size == 0 {
|
||||
// this is now the empty list
|
||||
list.start = -1
|
||||
list.end = -1
|
||||
} else {
|
||||
currentLength := list.length()
|
||||
start := list.start
|
||||
end := list.end
|
||||
// if we're truncating, keep the latest entries, not the earliest
|
||||
if size < currentLength {
|
||||
start = list.end - size
|
||||
if start < 0 {
|
||||
start += len(list.buffer)
|
||||
}
|
||||
// update lastDiscarded for discarded entries
|
||||
for i := list.start; i != start; i = (i + 1) % len(list.buffer) {
|
||||
if list.lastDiscarded.Before(list.buffer[i].Time) {
|
||||
list.lastDiscarded = list.buffer[i].Time
|
||||
}
|
||||
}
|
||||
}
|
||||
if start < end {
|
||||
copied := copy(newbuffer, list.buffer[start:end])
|
||||
list.start = 0
|
||||
list.end = copied % size
|
||||
} else {
|
||||
lenInitial := len(list.buffer) - start
|
||||
copied := copy(newbuffer, list.buffer[start:])
|
||||
copied += copy(newbuffer[lenInitial:], list.buffer[:end])
|
||||
list.start = 0
|
||||
list.end = copied % size
|
||||
}
|
||||
}
|
||||
|
||||
list.buffer = newbuffer
|
||||
}
|
||||
|
||||
func (hist *Buffer) length() int {
|
||||
if hist.start == -1 {
|
||||
return 0
|
||||
} else if hist.start < hist.end {
|
||||
return hist.end - hist.start
|
||||
} else {
|
||||
return len(hist.buffer) - (hist.start - hist.end)
|
||||
}
|
||||
}
|
156
irc/history/history_test.go
Normal file
156
irc/history/history_test.go
Normal file
@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// released under the MIT license
|
||||
|
||||
package history
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
timeFormat = "2006-01-02 15:04:05Z"
|
||||
)
|
||||
|
||||
func TestEmptyBuffer(t *testing.T) {
|
||||
pastTime := easyParse(timeFormat)
|
||||
|
||||
buf := NewHistoryBuffer(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())
|
||||
if len(since) != 0 {
|
||||
t.Error("shouldn't be able to add to disabled buf")
|
||||
}
|
||||
if complete {
|
||||
t.Error("the empty/disabled buffer should report results as incomplete")
|
||||
}
|
||||
|
||||
buf.Resize(1)
|
||||
if !buf.Enabled() {
|
||||
t.Error("the buffer of size 1 must be considered enabled")
|
||||
}
|
||||
since, complete = buf.Between(pastTime, time.Now())
|
||||
assertEqual(complete, true, t)
|
||||
assertEqual(len(since), 0, t)
|
||||
buf.Add(Item{
|
||||
Nick: "testnick",
|
||||
})
|
||||
since, complete = buf.Between(pastTime, time.Now())
|
||||
if len(since) != 1 {
|
||||
t.Error("should be able to store items in a nonempty buffer")
|
||||
}
|
||||
if !complete {
|
||||
t.Error("results should be complete")
|
||||
}
|
||||
if since[0].Nick != "testnick" {
|
||||
t.Error("retrived junk data")
|
||||
}
|
||||
|
||||
buf.Add(Item{
|
||||
Nick: "testnick2",
|
||||
})
|
||||
since, complete = buf.Between(pastTime, time.Now())
|
||||
if len(since) != 1 {
|
||||
t.Error("expect exactly 1 item")
|
||||
}
|
||||
if complete {
|
||||
t.Error("results must be marked incomplete")
|
||||
}
|
||||
if since[0].Nick != "testnick2" {
|
||||
t.Error("retrieved junk data")
|
||||
}
|
||||
assertEqual(toNicks(buf.All()), []string{"testnick2"}, t)
|
||||
}
|
||||
|
||||
func toNicks(items []Item) (result []string) {
|
||||
result = make([]string, len(items))
|
||||
for i, item := range items {
|
||||
result[i] = item.Nick
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func easyParse(timestamp string) time.Time {
|
||||
result, err := time.Parse(timeFormat, timestamp)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertEqual(supplied, expected interface{}, t *testing.T) {
|
||||
if !reflect.DeepEqual(supplied, expected) {
|
||||
t.Errorf("expected %v but got %v", expected, supplied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuffer(t *testing.T) {
|
||||
start := easyParse("2006-01-01 00:00:00Z")
|
||||
|
||||
buf := NewHistoryBuffer(3)
|
||||
buf.Add(Item{
|
||||
Nick: "testnick0",
|
||||
Time: easyParse("2006-01-01 15:04:05Z"),
|
||||
})
|
||||
|
||||
buf.Add(Item{
|
||||
Nick: "testnick1",
|
||||
Time: easyParse("2006-01-02 15:04:05Z"),
|
||||
})
|
||||
|
||||
buf.Add(Item{
|
||||
Nick: "testnick2",
|
||||
Time: easyParse("2006-01-03 15:04:05Z"),
|
||||
})
|
||||
|
||||
since, complete := buf.Between(start, time.Now())
|
||||
assertEqual(complete, true, t)
|
||||
assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
|
||||
|
||||
// add another item, evicting the first
|
||||
buf.Add(Item{
|
||||
Nick: "testnick3",
|
||||
Time: easyParse("2006-01-04 15:04:05Z"),
|
||||
})
|
||||
since, complete = buf.Between(start, time.Now())
|
||||
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())
|
||||
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"))
|
||||
assertEqual(complete, true, t)
|
||||
assertEqual(toNicks(since), []string{"testnick1"}, t)
|
||||
|
||||
// shrink the buffer, cutting off testnick1
|
||||
buf.Resize(2)
|
||||
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now())
|
||||
assertEqual(complete, false, t)
|
||||
assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
|
||||
|
||||
buf.Resize(5)
|
||||
buf.Add(Item{
|
||||
Nick: "testnick4",
|
||||
Time: easyParse("2006-01-05 15:04:05Z"),
|
||||
})
|
||||
buf.Add(Item{
|
||||
Nick: "testnick5",
|
||||
Time: easyParse("2006-01-06 15:04:05Z"),
|
||||
})
|
||||
buf.Add(Item{
|
||||
Nick: "testnick6",
|
||||
Time: easyParse("2006-01-07 15:04:05Z"),
|
||||
})
|
||||
since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now())
|
||||
assertEqual(complete, true, t)
|
||||
assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t)
|
||||
}
|
@ -353,6 +353,11 @@ func (set *ModeSet) SetMode(mode Mode, on bool) (applied bool) {
|
||||
return utils.BitsetSet(set[:], uint(mode)-minMode, on)
|
||||
}
|
||||
|
||||
// copy the contents of another modeset on top of this one
|
||||
func (set *ModeSet) Copy(other *ModeSet) {
|
||||
utils.BitsetCopy(set[:], other[:])
|
||||
}
|
||||
|
||||
// return the modes in the set as a slice
|
||||
func (set *ModeSet) AllModes() (result []Mode) {
|
||||
if set == nil {
|
||||
|
@ -81,6 +81,24 @@ func (manager *MonitorManager) Remove(client *Client, nick string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *MonitorManager) Resume(newClient, oldClient *Client) error {
|
||||
manager.Lock()
|
||||
defer manager.Unlock()
|
||||
|
||||
// newClient is now watching everyone oldClient was watching
|
||||
oldTargets := manager.watching[oldClient]
|
||||
delete(manager.watching, oldClient)
|
||||
manager.watching[newClient] = oldTargets
|
||||
|
||||
// update watchedby as well
|
||||
for watchedNick := range oldTargets {
|
||||
delete(manager.watchedby[watchedNick], oldClient)
|
||||
manager.watchedby[watchedNick][newClient] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAll unregisters `client` from receiving notifications about *all* nicks.
|
||||
func (manager *MonitorManager) RemoveAll(client *Client) {
|
||||
manager.Lock()
|
||||
|
@ -16,7 +16,8 @@ import (
|
||||
|
||||
var (
|
||||
restrictedNicknames = map[string]bool{
|
||||
"=scene=": true, // used for rp commands
|
||||
"=scene=": true, // used for rp commands
|
||||
"HistServ": true, // TODO(slingamn) this should become a real service
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/goshuirc/irc-go/ircmsg"
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
)
|
||||
|
||||
// ResponseBuffer - put simply - buffers messages and then outputs them to a given client.
|
||||
@ -19,6 +20,7 @@ type ResponseBuffer struct {
|
||||
Label string
|
||||
target *Client
|
||||
messages []ircmsg.IrcMessage
|
||||
blocking bool
|
||||
}
|
||||
|
||||
// GetLabel returns the label from the given message.
|
||||
@ -33,6 +35,10 @@ func NewResponseBuffer(target *Client) *ResponseBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
func (rb *ResponseBuffer) SetBlocking(blocking bool) {
|
||||
rb.blocking = blocking
|
||||
}
|
||||
|
||||
// Add adds a standard new message to our queue.
|
||||
func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) {
|
||||
message := ircmsg.MakeMessage(tags, prefix, command, params...)
|
||||
@ -63,11 +69,11 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, from *Client, tags *map[st
|
||||
}
|
||||
|
||||
// AddSplitMessageFromClient adds a new split message from a specific client to our queue.
|
||||
func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message SplitMessage) {
|
||||
if rb.target.capabilities.Has(caps.MaxLine) {
|
||||
rb.AddFromClient(msgid, from, tags, command, target, message.ForMaxLine)
|
||||
func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message utils.SplitMessage) {
|
||||
if rb.target.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
|
||||
rb.AddFromClient(msgid, from, tags, command, target, message.Original)
|
||||
} else {
|
||||
for _, str := range message.For512 {
|
||||
for _, str := range message.Wrapped {
|
||||
rb.AddFromClient(msgid, from, tags, command, target, str)
|
||||
}
|
||||
}
|
||||
@ -103,7 +109,7 @@ func (rb *ResponseBuffer) Send() error {
|
||||
for _, message := range rb.messages {
|
||||
// attach server-time if needed
|
||||
if rb.target.capabilities.Has(caps.ServerTime) {
|
||||
t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
|
||||
t := time.Now().UTC().Format(IRCv3TimestampFormat)
|
||||
message.Tags["time"] = ircmsg.MakeTagValue(t)
|
||||
}
|
||||
|
||||
@ -113,7 +119,7 @@ func (rb *ResponseBuffer) Send() error {
|
||||
}
|
||||
|
||||
// send message out
|
||||
rb.target.SendRawMessage(message)
|
||||
rb.target.SendRawMessage(message, rb.blocking)
|
||||
}
|
||||
|
||||
// end batch if required
|
||||
@ -122,7 +128,7 @@ func (rb *ResponseBuffer) Send() error {
|
||||
}
|
||||
|
||||
// clear out any existing messages
|
||||
rb.messages = []ircmsg.IrcMessage{}
|
||||
rb.messages = rb.messages[:0]
|
||||
|
||||
return nil
|
||||
}
|
||||
|
122
irc/server.go
122
irc/server.go
@ -430,6 +430,8 @@ func (server *Server) tryRegister(c *Client) {
|
||||
// continue registration
|
||||
server.logger.Debug("localconnect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", c.nick, c.username, c.realname))
|
||||
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", c.nick, c.username, c.rawHostname, c.IPString(), c.realname))
|
||||
|
||||
// "register"; this includes the initial phase of session resumption
|
||||
c.Register()
|
||||
|
||||
// send welcome text
|
||||
@ -455,41 +457,7 @@ func (server *Server) tryRegister(c *Client) {
|
||||
}
|
||||
|
||||
// if resumed, send fake channel joins
|
||||
if c.resumeDetails != nil {
|
||||
for _, name := range c.resumeDetails.SendFakeJoinsFor {
|
||||
channel := server.channels.Get(name)
|
||||
if channel == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if c.capabilities.Has(caps.ExtendedJoin) {
|
||||
c.Send(nil, c.nickMaskString, "JOIN", channel.name, c.AccountName(), c.realname)
|
||||
} else {
|
||||
c.Send(nil, c.nickMaskString, "JOIN", channel.name)
|
||||
}
|
||||
// reuse the last rb
|
||||
channel.SendTopic(c, rb)
|
||||
channel.Names(c, rb)
|
||||
rb.Send()
|
||||
|
||||
// construct and send fake modestring if necessary
|
||||
c.stateMutex.RLock()
|
||||
myModes := channel.members[c]
|
||||
c.stateMutex.RUnlock()
|
||||
if myModes == nil {
|
||||
continue
|
||||
}
|
||||
oldModes := myModes.String()
|
||||
if 0 < len(oldModes) {
|
||||
params := []string{channel.name, "+" + oldModes}
|
||||
for range oldModes {
|
||||
params = append(params, c.nick)
|
||||
}
|
||||
|
||||
c.Send(nil, server.name, "MODE", params...)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.tryResumeChannels()
|
||||
}
|
||||
|
||||
// t returns the translated version of the given string, based on the languages configured by the client.
|
||||
@ -519,69 +487,6 @@ func (server *Server) MOTD(client *Client, rb *ResponseBuffer) {
|
||||
rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
|
||||
}
|
||||
|
||||
// wordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
|
||||
func wordWrap(text string, lineWidth int) []string {
|
||||
var lines []string
|
||||
var cacheLine, cacheWord string
|
||||
|
||||
for _, char := range text {
|
||||
if char == '\r' {
|
||||
continue
|
||||
} else if char == '\n' {
|
||||
cacheLine += cacheWord
|
||||
lines = append(lines, cacheLine)
|
||||
cacheWord = ""
|
||||
cacheLine = ""
|
||||
} else if (char == ' ' || char == '-') && len(cacheLine)+len(cacheWord)+1 < lineWidth {
|
||||
// natural word boundary
|
||||
cacheLine += cacheWord + string(char)
|
||||
cacheWord = ""
|
||||
} else if lineWidth <= len(cacheLine)+len(cacheWord)+1 {
|
||||
// time to wrap to next line
|
||||
if len(cacheLine) < (lineWidth / 2) {
|
||||
// this word takes up more than half a line... just split in the middle of the word
|
||||
cacheLine += cacheWord + string(char)
|
||||
cacheWord = ""
|
||||
} else {
|
||||
cacheWord += string(char)
|
||||
}
|
||||
lines = append(lines, cacheLine)
|
||||
cacheLine = ""
|
||||
} else {
|
||||
// normal character
|
||||
cacheWord += string(char)
|
||||
}
|
||||
}
|
||||
if 0 < len(cacheWord) {
|
||||
cacheLine += cacheWord
|
||||
}
|
||||
if 0 < len(cacheLine) {
|
||||
lines = append(lines, cacheLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// SplitMessage represents a message that's been split for sending.
|
||||
type SplitMessage struct {
|
||||
For512 []string
|
||||
ForMaxLine string
|
||||
}
|
||||
|
||||
func (server *Server) splitMessage(original string, origIs512 bool) SplitMessage {
|
||||
var newSplit SplitMessage
|
||||
|
||||
newSplit.ForMaxLine = original
|
||||
|
||||
if !origIs512 {
|
||||
newSplit.For512 = wordWrap(original, 400)
|
||||
} else {
|
||||
newSplit.For512 = []string{original}
|
||||
}
|
||||
|
||||
return newSplit
|
||||
}
|
||||
|
||||
// WhoisChannelsNames returns the common channel names between two users.
|
||||
func (client *Client) WhoisChannelsNames(target *Client) []string {
|
||||
isMultiPrefix := client.capabilities.Has(caps.MultiPrefix)
|
||||
@ -817,6 +722,20 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
|
||||
updatedCaps.Add(caps.STS)
|
||||
}
|
||||
|
||||
// resize history buffers as needed
|
||||
if oldConfig != nil {
|
||||
if oldConfig.History.ChannelLength != config.History.ChannelLength {
|
||||
for _, channel := range server.channels.Channels() {
|
||||
channel.history.Resize(config.History.ChannelLength)
|
||||
}
|
||||
}
|
||||
if oldConfig.History.ClientLength != config.History.ClientLength {
|
||||
for _, client := range server.clients.AllClients() {
|
||||
client.history.Resize(config.History.ClientLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// burst new and removed caps
|
||||
var capBurstClients ClientSet
|
||||
added := make(map[caps.Version]string)
|
||||
@ -1107,13 +1026,6 @@ func (target *Client) RplList(channel *Channel, rb *ResponseBuffer) {
|
||||
rb.Add(nil, target.server.name, RPL_LIST, target.nick, channel.name, strconv.Itoa(memberCount), channel.topic)
|
||||
}
|
||||
|
||||
// ResumeDetails are the details that we use to resume connections.
|
||||
type ResumeDetails struct {
|
||||
OldNick string
|
||||
Timestamp *time.Time
|
||||
SendFakeJoinsFor []string
|
||||
}
|
||||
|
||||
var (
|
||||
infoString1 = strings.Split(` ▄▄▄ ▄▄▄· ▄▄ • ▐ ▄
|
||||
▪ ▀▄ █·▐█ ▀█ ▐█ ▀ ▪▪ •█▌▐█▪
|
||||
|
@ -134,6 +134,7 @@ func (socket *Socket) Write(data []byte) (err error) {
|
||||
prospectiveLen := socket.totalLength + len(data)
|
||||
if prospectiveLen > socket.maxSendQBytes {
|
||||
socket.sendQExceeded = true
|
||||
socket.closed = true
|
||||
err = errSendQExceeded
|
||||
} else {
|
||||
socket.buffers = append(socket.buffers, data)
|
||||
@ -146,6 +147,45 @@ func (socket *Socket) Write(data []byte) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// BlockingWrite sends the given string out of Socket. Requirements:
|
||||
// 1. MUST block until the message is sent
|
||||
// 2. MUST bypass sendq (calls to BlockingWrite cannot, on their own, cause a sendq overflow)
|
||||
// 3. MUST provide mutual exclusion for socket.conn.Write
|
||||
// 4. MUST respect the same ordering guarantees as Write (i.e., if a call to Write that sends
|
||||
// message m1 happens-before a call to BlockingWrite that sends message m2,
|
||||
// m1 must be sent on the wire before m2
|
||||
// Callers MUST be writing to the client's socket from the client's own goroutine;
|
||||
// other callers must use the nonblocking Write call instead. Otherwise, a client
|
||||
// with a slow/unreliable connection risks stalling the progress of the system as a whole.
|
||||
func (socket *Socket) BlockingWrite(data []byte) (err error) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// after releasing the semaphore, we must check for fresh data, same as `send`
|
||||
defer func() {
|
||||
if socket.readyToWrite() {
|
||||
socket.wakeWriter()
|
||||
}
|
||||
}()
|
||||
|
||||
// blocking acquire of the trylock
|
||||
socket.writerSemaphore.Acquire()
|
||||
defer socket.writerSemaphore.Release()
|
||||
|
||||
// first, flush any buffered data, to preserve the ordering guarantees
|
||||
closed := socket.performWrite()
|
||||
if closed {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
_, err = socket.conn.Write(data)
|
||||
if err != nil {
|
||||
socket.finalize()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// wakeWriter starts the goroutine that actually performs the write, without blocking
|
||||
func (socket *Socket) wakeWriter() {
|
||||
if socket.writerSemaphore.TryAcquire() {
|
||||
@ -174,7 +214,7 @@ func (socket *Socket) readyToWrite() bool {
|
||||
socket.Lock()
|
||||
defer socket.Unlock()
|
||||
// on the first time observing socket.closed, we still have to write socket.finalData
|
||||
return !socket.finalized && (socket.totalLength > 0 || socket.closed || socket.sendQExceeded)
|
||||
return !socket.finalized && (socket.totalLength > 0 || socket.closed)
|
||||
}
|
||||
|
||||
// send actually writes messages to socket.Conn; it may block
|
||||
@ -199,34 +239,46 @@ func (socket *Socket) send() {
|
||||
}
|
||||
|
||||
// write the contents of the buffer, then see if we need to close
|
||||
func (socket *Socket) performWrite() {
|
||||
// returns whether we closed
|
||||
func (socket *Socket) performWrite() (closed bool) {
|
||||
// retrieve the buffered data, clear the buffer
|
||||
socket.Lock()
|
||||
buffers := socket.buffers
|
||||
socket.buffers = nil
|
||||
socket.totalLength = 0
|
||||
closed = socket.closed
|
||||
socket.Unlock()
|
||||
|
||||
// on Linux, the runtime will optimize this into a single writev(2) call:
|
||||
_, err := (*net.Buffers)(&buffers).WriteTo(socket.conn)
|
||||
|
||||
socket.Lock()
|
||||
shouldClose := (err != nil) || socket.closed || socket.sendQExceeded
|
||||
socket.Unlock()
|
||||
|
||||
if !shouldClose {
|
||||
return
|
||||
var err error
|
||||
if !closed && len(buffers) > 0 {
|
||||
// on Linux, the runtime will optimize this into a single writev(2) call:
|
||||
_, err = (*net.Buffers)(&buffers).WriteTo(socket.conn)
|
||||
}
|
||||
|
||||
closed = closed || err != nil
|
||||
if closed {
|
||||
socket.finalize()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// mark closed and send final data. you must be holding the semaphore to call this:
|
||||
func (socket *Socket) finalize() {
|
||||
// mark the socket closed (if someone hasn't already), then write error lines
|
||||
socket.Lock()
|
||||
socket.closed = true
|
||||
finalized := socket.finalized
|
||||
socket.finalized = true
|
||||
finalData := socket.finalData
|
||||
if socket.sendQExceeded {
|
||||
finalData = "\r\nERROR :SendQ Exceeded\r\n"
|
||||
}
|
||||
socket.Unlock()
|
||||
|
||||
if finalized {
|
||||
return
|
||||
}
|
||||
|
||||
if finalData != "" {
|
||||
socket.conn.Write([]byte(finalData))
|
||||
}
|
||||
|
@ -6,6 +6,7 @@
|
||||
package irc
|
||||
|
||||
import "github.com/oragono/oragono/irc/modes"
|
||||
import "github.com/goshuirc/irc-go/ircmsg"
|
||||
|
||||
// ClientSet is a set of clients.
|
||||
type ClientSet map[*Client]bool
|
||||
@ -56,3 +57,5 @@ func (members MemberSet) AnyHasMode(mode modes.Mode) bool {
|
||||
|
||||
// ChannelSet is a set of channels.
|
||||
type ChannelSet map[*Channel]bool
|
||||
|
||||
type Tags *map[string]ircmsg.TagValue
|
||||
|
@ -80,3 +80,12 @@ func BitsetUnion(set []uint64, other []uint64) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BitsetCopy copies the contents of `other` over `set`.
|
||||
// Similar caveats about race conditions as with `BitsetUnion` apply.
|
||||
func BitsetCopy(set []uint64, other []uint64) {
|
||||
for i := 0; i < len(set); i++ {
|
||||
data := atomic.LoadUint64(&other[i])
|
||||
atomic.StoreUint64(&set[i], data)
|
||||
}
|
||||
}
|
||||
|
@ -62,4 +62,19 @@ func TestSets(t *testing.T) {
|
||||
t.Error("all bits should be set except 72")
|
||||
}
|
||||
}
|
||||
|
||||
var t3 testBitset
|
||||
t3s := t3[:]
|
||||
BitsetSet(t3s, 72, true)
|
||||
if !BitsetGet(t3s, 72) {
|
||||
t.Error("bit 72 should be set")
|
||||
}
|
||||
// copy t1 on top of t2
|
||||
BitsetCopy(t3s, t1s)
|
||||
for i = 0; i < 128; i++ {
|
||||
expected := (i != 72)
|
||||
if BitsetGet(t1s, i) != expected {
|
||||
t.Error("all bits should be set except 72")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
30
irc/utils/crypto.go
Normal file
30
irc/utils/crypto.go
Normal file
@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// released under the MIT license
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// generate a secret token that cannot be brute-forced via online attacks
|
||||
func GenerateSecretToken() string {
|
||||
// 128 bits of entropy are enough to resist any online attack:
|
||||
var buf [16]byte
|
||||
rand.Read(buf[:])
|
||||
// 32 ASCII characters, should be fine for most purposes
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
// securely check if a supplied token matches a stored token
|
||||
func SecretTokensMatch(storedToken string, suppliedToken string) bool {
|
||||
// XXX fix a potential gotcha: if the stored token is uninitialized,
|
||||
// then nothing should match it, not even supplying an empty token.
|
||||
if len(storedToken) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(storedToken), []byte(suppliedToken)) == 1
|
||||
}
|
48
irc/utils/crypto_test.go
Normal file
48
irc/utils/crypto_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// released under the MIT license
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
storedToken = "1e82d113a59a874cccf82063ec603221"
|
||||
badToken = "1e82d113a59a874cccf82063ec603222"
|
||||
shortToken = "1e82d113a59a874cccf82063ec60322"
|
||||
longToken = "1e82d113a59a874cccf82063ec6032211"
|
||||
)
|
||||
|
||||
func TestGenerateSecretToken(t *testing.T) {
|
||||
token := GenerateSecretToken()
|
||||
if len(token) != 32 {
|
||||
t.Errorf("bad token: %v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCompare(t *testing.T) {
|
||||
if !SecretTokensMatch(storedToken, storedToken) {
|
||||
t.Error("matching tokens must match")
|
||||
}
|
||||
|
||||
if SecretTokensMatch(storedToken, badToken) {
|
||||
t.Error("non-matching tokens must not match")
|
||||
}
|
||||
|
||||
if SecretTokensMatch(storedToken, shortToken) {
|
||||
t.Error("non-matching tokens must not match")
|
||||
}
|
||||
|
||||
if SecretTokensMatch(storedToken, longToken) {
|
||||
t.Error("non-matching tokens must not match")
|
||||
}
|
||||
|
||||
if SecretTokensMatch("", "") {
|
||||
t.Error("the empty token should not match anything")
|
||||
}
|
||||
|
||||
if SecretTokensMatch("", storedToken) {
|
||||
t.Error("the empty token should not match anything")
|
||||
}
|
||||
}
|
67
irc/utils/text.go
Normal file
67
irc/utils/text.go
Normal file
@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
|
||||
// released under the MIT license
|
||||
|
||||
package utils
|
||||
|
||||
import "bytes"
|
||||
|
||||
// WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
|
||||
func WordWrap(text string, lineWidth int) []string {
|
||||
var lines []string
|
||||
var cacheLine, cacheWord bytes.Buffer
|
||||
|
||||
for _, char := range text {
|
||||
if char == '\r' {
|
||||
continue
|
||||
} else if char == '\n' {
|
||||
cacheLine.Write(cacheWord.Bytes())
|
||||
lines = append(lines, cacheLine.String())
|
||||
cacheWord.Reset()
|
||||
cacheLine.Reset()
|
||||
} else if (char == ' ' || char == '-') && cacheLine.Len()+cacheWord.Len()+1 < lineWidth {
|
||||
// natural word boundary
|
||||
cacheLine.Write(cacheWord.Bytes())
|
||||
cacheLine.WriteRune(char)
|
||||
cacheWord.Reset()
|
||||
} else if lineWidth <= cacheLine.Len()+cacheWord.Len()+1 {
|
||||
// time to wrap to next line
|
||||
if cacheLine.Len() < (lineWidth / 2) {
|
||||
// this word takes up more than half a line... just split in the middle of the word
|
||||
cacheLine.Write(cacheWord.Bytes())
|
||||
cacheLine.WriteRune(char)
|
||||
cacheWord.Reset()
|
||||
} else {
|
||||
cacheWord.WriteRune(char)
|
||||
}
|
||||
lines = append(lines, cacheLine.String())
|
||||
cacheLine.Reset()
|
||||
} else {
|
||||
// normal character
|
||||
cacheWord.WriteRune(char)
|
||||
}
|
||||
}
|
||||
if 0 < cacheWord.Len() {
|
||||
cacheLine.Write(cacheWord.Bytes())
|
||||
}
|
||||
if 0 < cacheLine.Len() {
|
||||
lines = append(lines, cacheLine.String())
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// SplitMessage represents a message that's been split for sending.
|
||||
type SplitMessage struct {
|
||||
Original string
|
||||
Wrapped []string // if this is nil, Original didn't need wrapping and can be sent to anyone
|
||||
}
|
||||
|
||||
func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) {
|
||||
result.Original = original
|
||||
|
||||
if !origIs512 {
|
||||
result.Wrapped = WordWrap(original, 400)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
60
irc/utils/text_test.go
Normal file
60
irc/utils/text_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// released under the MIT license
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
threeMusketeers = "In the meantime D’Artagnan, who had plunged into a bypath, continued his route and reached St. Cloud; but instead of following the main street he turned behind the château, reached a sort of retired lane, and found himself soon in front of the pavilion named. It was situated in a very private spot. A high wall, at the angle of which was the pavilion, ran along one side of this lane, and on the other was a little garden connected with a poor cottage which was protected by a hedge from passers-by."
|
||||
|
||||
monteCristo = `Both the count and Baptistin had told the truth when they announced to Morcerf the proposed visit of the major, which had served Monte Cristo as a pretext for declining Albert's invitation. Seven o'clock had just struck, and M. Bertuccio, according to the command which had been given him, had two hours before left for Auteuil, when a cab stopped at the door, and after depositing its occupant at the gate, immediately hurried away, as if ashamed of its employment. The visitor was about fifty-two years of age, dressed in one of the green surtouts, ornamented with black frogs, which have so long maintained their popularity all over Europe. He wore trousers of blue cloth, boots tolerably clean, but not of the brightest polish, and a little too thick in the soles, buckskin gloves, a hat somewhat resembling in shape those usually worn by the gendarmes, and a black cravat striped with white, which, if the proprietor had not worn it of his own free will, might have passed for a halter, so much did it resemble one. Such was the picturesque costume of the person who rang at the gate, and demanded if it was not at No. 30 in the Avenue des Champs-Elysees that the Count of Monte Cristo lived, and who, being answered by the porter in the affirmative, entered, closed the gate after him, and began to ascend the steps.`
|
||||
)
|
||||
|
||||
func assertWrapCorrect(text string, lineWidth int, allowSplitWords bool, t *testing.T) {
|
||||
lines := WordWrap(text, lineWidth)
|
||||
|
||||
reconstructed := strings.Join(lines, "")
|
||||
if text != reconstructed {
|
||||
t.Errorf("text %v does not match original %v", text, reconstructed)
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
if len(line) > lineWidth {
|
||||
t.Errorf("line too long: %d, %v", len(line), line)
|
||||
}
|
||||
}
|
||||
|
||||
if !allowSplitWords {
|
||||
origWords := strings.Fields(text)
|
||||
var newWords []string
|
||||
for _, line := range lines {
|
||||
newWords = append(newWords, strings.Fields(line)...)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(origWords, newWords) {
|
||||
t.Errorf("words %v do not match wrapped words %v", origWords, newWords)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestWordWrap(t *testing.T) {
|
||||
assertWrapCorrect("jackdaws love my big sphinx of quartz", 12, false, t)
|
||||
// long word that will necessarily be split:
|
||||
assertWrapCorrect("jackdawslovemybigsphinxofquartz", 12, true, t)
|
||||
|
||||
assertWrapCorrect(threeMusketeers, 40, true, t)
|
||||
assertWrapCorrect(monteCristo, 20, false, t)
|
||||
}
|
||||
|
||||
func BenchmarkWordWrap(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
WordWrap(threeMusketeers, 40)
|
||||
WordWrap(monteCristo, 60)
|
||||
}
|
||||
}
|
15
oragono.yaml
15
oragono.yaml
@ -92,6 +92,10 @@ server:
|
||||
# - "127.0.0.1/8"
|
||||
# - "0::1"
|
||||
|
||||
# allow use of the RESUME extension over plaintext connections:
|
||||
# do not enable this unless the ircd is only accessible over internal networks
|
||||
allow-plaintext-resume: false
|
||||
|
||||
# maximum length of clients' sendQ in bytes
|
||||
# this should be big enough to hold /LIST and HELP replies
|
||||
max-sendq: 16k
|
||||
@ -439,3 +443,14 @@ fakelag:
|
||||
# client status resets to the default state if they go this long without
|
||||
# sending any commands:
|
||||
cooldown: 2s
|
||||
|
||||
# message history tracking, for the RESUME extension and possibly other uses in future
|
||||
history:
|
||||
# should we store messages for later playback?
|
||||
enabled: true
|
||||
|
||||
# how many channel-specific events (messages, joins, parts) should be tracked per channel?
|
||||
channel-length: 256
|
||||
|
||||
# how many direct messages and notices should be tracked per user?
|
||||
client-length: 64
|
||||
|
Loading…
Reference in New Issue
Block a user