Browse Source

draft/resume-0.2 implementation, message history support

tags/v1.0.0-rc1
Shivaram Lingamneni 5 years ago
parent
commit
a0bf548fc5

+ 1
- 0
Makefile View File

@@ -20,6 +20,7 @@ test:
20 20
 	python3 ./gencapdefs.py | diff - ${capdef_file}
21 21
 	cd irc && go test . && go vet .
22 22
 	cd irc/caps && go test . && go vet .
23
+	cd irc/history && go test . && go vet .
23 24
 	cd irc/isupport && go test . && go vet .
24 25
 	cd irc/modes && go test . && go vet .
25 26
 	cd irc/passwd && go test . && go vet .

+ 1
- 1
gencapdefs.py View File

@@ -107,7 +107,7 @@ CAPDEFS = [
107 107
     ),
108 108
     CapDef(
109 109
         identifier="Resume",
110
-        name="draft/resume",
110
+        name="draft/resume-0.2",
111 111
         url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md",
112 112
         standard="proposed IRCv3",
113 113
     ),

+ 3
- 7
irc/accounts.go View File

@@ -4,9 +4,6 @@
4 4
 package irc
5 5
 
6 6
 import (
7
-	"crypto/rand"
8
-	"crypto/subtle"
9
-	"encoding/hex"
10 7
 	"encoding/json"
11 8
 	"errors"
12 9
 	"fmt"
@@ -20,6 +17,7 @@ import (
20 17
 
21 18
 	"github.com/oragono/oragono/irc/caps"
22 19
 	"github.com/oragono/oragono/irc/passwd"
20
+	"github.com/oragono/oragono/irc/utils"
23 21
 	"github.com/tidwall/buntdb"
24 22
 )
25 23
 
@@ -336,9 +334,7 @@ func (am *AccountManager) dispatchCallback(client *Client, casefoldedAccount str
336 334
 
337 335
 func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccount string, callbackValue string) (code string, err error) {
338 336
 	config := am.server.AccountConfig().Registration.Callbacks.Mailto
339
-	buf := make([]byte, 16)
340
-	rand.Read(buf)
341
-	code = hex.EncodeToString(buf)
337
+	code = utils.GenerateSecretToken()
342 338
 
343 339
 	subject := config.VerifyMessageSubject
344 340
 	if subject == "" {
@@ -412,7 +408,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
412 408
 			storedCode, err := tx.Get(verificationCodeKey)
413 409
 			if err == nil {
414 410
 				// this is probably unnecessary
415
-				if storedCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(storedCode)) == 1 {
411
+				if storedCode == "" || utils.SecretTokensMatch(storedCode, code) {
416 412
 					success = true
417 413
 				}
418 414
 			}

+ 2
- 2
irc/caps/defs.go View File

@@ -73,7 +73,7 @@ const (
73 73
 	// https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md
74 74
 	Rename Capability = iota
75 75
 
76
-	// Resume is the proposed IRCv3 capability named "draft/resume":
76
+	// Resume is the proposed IRCv3 capability named "draft/resume-0.2":
77 77
 	// https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md
78 78
 	Resume Capability = iota
79 79
 
@@ -112,7 +112,7 @@ var (
112 112
 		"draft/message-tags-0.2",
113 113
 		"multi-prefix",
114 114
 		"draft/rename",
115
-		"draft/resume",
115
+		"draft/resume-0.2",
116 116
 		"sasl",
117 117
 		"server-time",
118 118
 		"sts",

+ 159
- 15
irc/channel.go View File

@@ -7,7 +7,6 @@ package irc
7 7
 
8 8
 import (
9 9
 	"bytes"
10
-	"crypto/subtle"
11 10
 	"fmt"
12 11
 	"strconv"
13 12
 	"time"
@@ -16,7 +15,9 @@ import (
16 15
 
17 16
 	"github.com/goshuirc/irc-go/ircmsg"
18 17
 	"github.com/oragono/oragono/irc/caps"
18
+	"github.com/oragono/oragono/irc/history"
19 19
 	"github.com/oragono/oragono/irc/modes"
20
+	"github.com/oragono/oragono/irc/utils"
20 21
 )
21 22
 
22 23
 // Channel represents a channel that clients can join.
@@ -39,6 +40,7 @@ type Channel struct {
39 40
 	topicSetTime      time.Time
40 41
 	userLimit         uint64
41 42
 	accountToUMode    map[string]modes.Mode
43
+	history           history.Buffer
42 44
 }
43 45
 
44 46
 // NewChannel creates a new channel from a `Server` and a `name`
@@ -65,14 +67,18 @@ func NewChannel(s *Server, name string, regInfo *RegisteredChannel) *Channel {
65 67
 		accountToUMode: make(map[string]modes.Mode),
66 68
 	}
67 69
 
70
+	config := s.Config()
71
+
68 72
 	if regInfo != nil {
69 73
 		channel.applyRegInfo(regInfo)
70 74
 	} else {
71
-		for _, mode := range s.DefaultChannelModes() {
75
+		for _, mode := range config.Channels.defaultModes {
72 76
 			channel.flags.SetMode(mode, true)
73 77
 		}
74 78
 	}
75 79
 
80
+	channel.history.Initialize(config.History.ChannelLength)
81
+
76 82
 	return channel
77 83
 }
78 84
 
@@ -214,9 +220,7 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
214 220
 		prefix := modes.Prefixes(isMultiPrefix)
215 221
 		if buffer.Len()+len(nick)+len(prefix)+1 > maxNamLen {
216 222
 			namesLines = append(namesLines, buffer.String())
217
-			// memset(&buffer, 0, sizeof(bytes.Buffer));
218
-			var newBuffer bytes.Buffer
219
-			buffer = newBuffer
223
+			buffer.Reset()
220 224
 		}
221 225
 		if buffer.Len() > 0 {
222 226
 			buffer.WriteString(" ")
@@ -344,11 +348,7 @@ func (channel *Channel) IsFull() bool {
344 348
 // CheckKey returns true if the key is not set or matches the given key.
345 349
 func (channel *Channel) CheckKey(key string) bool {
346 350
 	chkey := channel.Key()
347
-	if chkey == "" {
348
-		return true
349
-	}
350
-
351
-	return subtle.ConstantTimeCompare([]byte(key), []byte(chkey)) == 1
351
+	return chkey == "" || utils.SecretTokensMatch(chkey, key)
352 352
 }
353 353
 
354 354
 func (channel *Channel) IsEmpty() bool {
@@ -462,6 +462,12 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
462 462
 	if givenMode != 0 {
463 463
 		rb.Add(nil, client.server.name, "MODE", chname, modestr, nick)
464 464
 	}
465
+
466
+	channel.history.Add(history.Item{
467
+		Type:        history.Join,
468
+		Nick:        nickmask,
469
+		AccountName: accountName,
470
+	})
465 471
 }
466 472
 
467 473
 // 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)
480 486
 	}
481 487
 	rb.Add(nil, nickmask, "PART", chname, message)
482 488
 
489
+	channel.history.Add(history.Item{
490
+		Type:        history.Part,
491
+		Nick:        nickmask,
492
+		AccountName: client.AccountName(),
493
+		Message:     utils.MakeSplitMessage(message, true),
494
+	})
495
+
483 496
 	client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", client.nick, chname))
484 497
 }
485 498
 
499
+// Resume is called after a successful global resume to:
500
+// 1. Replace the old client with the new in the channel's data structures
501
+// 2. Send JOIN and MODE lines to channel participants (including the new client)
502
+// 3. Replay missed message history to the client
503
+func (channel *Channel) Resume(newClient, oldClient *Client, timestamp time.Time) {
504
+	now := time.Now()
505
+	channel.resumeAndAnnounce(newClient, oldClient)
506
+	if !timestamp.IsZero() {
507
+		channel.replayHistory(newClient, timestamp, now)
508
+	}
509
+}
510
+
511
+func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) {
512
+	var oldModeSet *modes.ModeSet
513
+
514
+	func() {
515
+		channel.joinPartMutex.Lock()
516
+		defer channel.joinPartMutex.Unlock()
517
+
518
+		defer channel.regenerateMembersCache()
519
+
520
+		channel.stateMutex.Lock()
521
+		defer channel.stateMutex.Unlock()
522
+
523
+		newClient.channels[channel] = true
524
+		oldModeSet = channel.members[oldClient]
525
+		if oldModeSet == nil {
526
+			oldModeSet = modes.NewModeSet()
527
+		}
528
+		channel.members.Remove(oldClient)
529
+		channel.members[newClient] = oldModeSet
530
+	}()
531
+
532
+	// construct fake modestring if necessary
533
+	oldModes := oldModeSet.String()
534
+	if 0 < len(oldModes) {
535
+		oldModes = "+" + oldModes
536
+	}
537
+
538
+	// send join for old clients
539
+	nick := newClient.Nick()
540
+	nickMask := newClient.NickMaskString()
541
+	accountName := newClient.AccountName()
542
+	realName := newClient.Realname()
543
+	for _, member := range channel.Members() {
544
+		if member.capabilities.Has(caps.Resume) {
545
+			continue
546
+		}
547
+
548
+		if member.capabilities.Has(caps.ExtendedJoin) {
549
+			member.Send(nil, nickMask, "JOIN", channel.name, accountName, realName)
550
+		} else {
551
+			member.Send(nil, nickMask, "JOIN", channel.name)
552
+		}
553
+
554
+		if 0 < len(oldModes) {
555
+			member.Send(nil, channel.server.name, "MODE", channel.name, oldModes, nick)
556
+		}
557
+	}
558
+
559
+	rb := NewResponseBuffer(newClient)
560
+	// use blocking i/o to synchronize with the later history replay
561
+	rb.SetBlocking(true)
562
+	if newClient.capabilities.Has(caps.ExtendedJoin) {
563
+		rb.Add(nil, nickMask, "JOIN", channel.name, accountName, realName)
564
+	} else {
565
+		rb.Add(nil, nickMask, "JOIN", channel.name)
566
+	}
567
+	channel.SendTopic(newClient, rb)
568
+	channel.Names(newClient, rb)
569
+	if 0 < len(oldModes) {
570
+		rb.Add(nil, newClient.server.name, "MODE", channel.name, oldModes, nick)
571
+	}
572
+	rb.Send()
573
+}
574
+
575
+func (channel *Channel) replayHistory(newClient *Client, after time.Time, before time.Time) {
576
+	chname := channel.Name()
577
+	extendedJoin := newClient.capabilities.Has(caps.ExtendedJoin)
578
+
579
+	items, complete := channel.history.Between(after, before)
580
+	for _, item := range items {
581
+		switch item.Type {
582
+		case history.Privmsg:
583
+			newClient.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, "PRIVMSG", chname, item.Message)
584
+		case history.Notice:
585
+			newClient.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, "NOTICE", chname, item.Message)
586
+		case history.Join:
587
+			if extendedJoin {
588
+				newClient.sendInternal(true, item.Time, nil, item.Nick, "JOIN", chname, item.AccountName, "")
589
+			} else {
590
+				newClient.sendInternal(true, item.Time, nil, item.Nick, "JOIN", chname)
591
+			}
592
+		case history.Quit:
593
+			// XXX: send QUIT as PART to avoid having to correctly deduplicate and synchronize
594
+			// QUIT messages across channels
595
+			fallthrough
596
+		case history.Part:
597
+			newClient.sendInternal(true, item.Time, nil, item.Nick, "PART", chname, item.Message.Original)
598
+		case history.Kick:
599
+			newClient.sendInternal(true, item.Time, nil, item.Nick, "KICK", chname, item.Msgid, item.Message.Original)
600
+		}
601
+	}
602
+
603
+	if !complete && !newClient.resumeDetails.HistoryIncomplete {
604
+		// warn here if we didn't warn already
605
+		newClient.sendInternal(true, time.Time{}, nil, "HistServ", "NOTICE", chname, newClient.t("Some additional message history may have been lost"))
606
+	}
607
+}
608
+
486 609
 // SendTopic sends the channel topic to the given client.
487 610
 func (channel *Channel) SendTopic(client *Client, rb *ResponseBuffer) {
488 611
 	if !channel.hasClient(client) {
@@ -622,16 +745,16 @@ func (channel *Channel) sendMessage(msgid, cmd string, requiredCaps []caps.Capab
622 745
 }
623 746
 
624 747
 // SplitPrivMsg sends a private message to everyone in this channel.
625
-func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) {
626
-	channel.sendSplitMessage(msgid, "PRIVMSG", minPrefix, clientOnlyTags, client, &message, rb)
748
+func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
749
+	channel.sendSplitMessage(msgid, "PRIVMSG", history.Privmsg, minPrefix, clientOnlyTags, client, &message, rb)
627 750
 }
628 751
 
629 752
 // SplitNotice sends a private message to everyone in this channel.
630
-func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) {
631
-	channel.sendSplitMessage(msgid, "NOTICE", minPrefix, clientOnlyTags, client, &message, rb)
753
+func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
754
+	channel.sendSplitMessage(msgid, "NOTICE", history.Notice, minPrefix, clientOnlyTags, client, &message, rb)
632 755
 }
633 756
 
634
-func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *SplitMessage, rb *ResponseBuffer) {
757
+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) {
635 758
 	if !channel.CanSpeak(client) {
636 759
 		rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel"))
637 760
 		return
@@ -654,6 +777,10 @@ func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mod
654 777
 			rb.AddSplitMessageFromClient(msgid, client, tagsToUse, cmd, channel.name, *message)
655 778
 		}
656 779
 	}
780
+
781
+	nickmask := client.NickMaskString()
782
+	account := client.AccountName()
783
+
657 784
 	for _, member := range channel.Members() {
658 785
 		if minPrefix != nil && !channel.ClientIsAtLeast(member, minPrefixMode) {
659 786
 			// STATUSMSG
@@ -668,12 +795,21 @@ func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mod
668 795
 			tagsToUse = clientOnlyTags
669 796
 		}
670 797
 
798
+		// TODO(slingamn) evaluate an optimization where we reuse `nickmask` and `account`
671 799
 		if message == nil {
672 800
 			member.SendFromClient(msgid, client, tagsToUse, cmd, channel.name)
673 801
 		} else {
674 802
 			member.SendSplitMsgFromClient(msgid, client, tagsToUse, cmd, channel.name, *message)
675 803
 		}
676 804
 	}
805
+
806
+	channel.history.Add(history.Item{
807
+		Type:        histType,
808
+		Msgid:       msgid,
809
+		Message:     *message,
810
+		Nick:        nickmask,
811
+		AccountName: account,
812
+	})
677 813
 }
678 814
 
679 815
 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
806 942
 		member.Send(nil, clientMask, "KICK", channel.name, targetNick, comment)
807 943
 	}
808 944
 
945
+	channel.history.Add(history.Item{
946
+		Type:        history.Kick,
947
+		Nick:        clientMask,
948
+		Message:     utils.MakeSplitMessage(comment, true),
949
+		AccountName: target.AccountName(),
950
+		Msgid:       targetNick, // XXX abuse this field
951
+	})
952
+
809 953
 	channel.Quit(target)
810 954
 }
811 955
 

+ 275
- 123
irc/client.go View File

@@ -19,6 +19,7 @@ import (
19 19
 	"github.com/goshuirc/irc-go/ircmsg"
20 20
 	ident "github.com/oragono/go-ident"
21 21
 	"github.com/oragono/oragono/irc/caps"
22
+	"github.com/oragono/oragono/irc/history"
22 23
 	"github.com/oragono/oragono/irc/modes"
23 24
 	"github.com/oragono/oragono/irc/sno"
24 25
 	"github.com/oragono/oragono/irc/utils"
@@ -26,13 +27,28 @@ import (
26 27
 
27 28
 const (
28 29
 	// IdentTimeoutSeconds is how many seconds before our ident (username) check times out.
29
-	IdentTimeoutSeconds = 1.5
30
+	IdentTimeoutSeconds  = 1.5
31
+	IRCv3TimestampFormat = "2006-01-02T15:04:05.999Z"
30 32
 )
31 33
 
32 34
 var (
33 35
 	LoopbackIP = net.ParseIP("127.0.0.1")
34 36
 )
35 37
 
38
+// ResumeDetails is a place to stash data at various stages of
39
+// the resume process: when handling the RESUME command itself,
40
+// when completing the registration, and when rejoining channels.
41
+type ResumeDetails struct {
42
+	OldClient         *Client
43
+	OldNick           string
44
+	OldNickMask       string
45
+	PresentedToken    string
46
+	Timestamp         time.Time
47
+	ResumedAt         time.Time
48
+	Channels          []string
49
+	HistoryIncomplete bool
50
+}
51
+
36 52
 // Client is an IRC client.
37 53
 type Client struct {
38 54
 	account            string
@@ -71,6 +87,7 @@ type Client struct {
71 87
 	realname           string
72 88
 	registered         bool
73 89
 	resumeDetails      *ResumeDetails
90
+	resumeToken        string
74 91
 	saslInProgress     bool
75 92
 	saslMechanism      string
76 93
 	saslValue          string
@@ -79,6 +96,7 @@ type Client struct {
79 96
 	stateMutex         sync.RWMutex // tier 1
80 97
 	username           string
81 98
 	vhost              string
99
+	history            *history.Buffer
82 100
 }
83 101
 
84 102
 // NewClient sets up a new client and starts its goroutine.
@@ -101,6 +119,7 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) {
101 119
 		nick:           "*", // * is used until actual nick is given
102 120
 		nickCasefolded: "*",
103 121
 		nickMaskString: "*", // * is used until actual nick is given
122
+		history:        history.NewHistoryBuffer(config.History.ClientLength),
104 123
 	}
105 124
 	client.languages = server.languages.Default()
106 125
 
@@ -350,124 +369,199 @@ func (client *Client) TryResume() {
350 369
 	}
351 370
 
352 371
 	server := client.server
353
-
354
-	// just grab these mutexes for safety. later we can work out whether we can grab+release them earlier
355
-	server.clients.Lock()
356
-	defer server.clients.Unlock()
357
-	server.channels.Lock()
358
-	defer server.channels.Unlock()
372
+	config := server.Config()
359 373
 
360 374
 	oldnick := client.resumeDetails.OldNick
361 375
 	timestamp := client.resumeDetails.Timestamp
362 376
 	var timestampString string
363
-	if timestamp != nil {
364
-		timestampString = timestamp.UTC().Format("2006-01-02T15:04:05.999Z")
377
+	if !timestamp.IsZero() {
378
+		timestampString = timestamp.UTC().Format(IRCv3TimestampFormat)
365 379
 	}
366 380
 
367
-	// can't use server.clients.Get since we hold server.clients' tier 1 mutex
368
-	casefoldedName, err := CasefoldName(oldnick)
369
-	if err != nil {
370
-		client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found"))
381
+	oldClient := server.clients.Get(oldnick)
382
+	if oldClient == nil {
383
+		client.Send(nil, server.name, "RESUME", "ERR", oldnick, client.t("Cannot resume connection, old client not found"))
384
+		client.resumeDetails = nil
371 385
 		return
372 386
 	}
387
+	oldNick := oldClient.Nick()
388
+	oldNickmask := oldClient.NickMaskString()
373 389
 
374
-	oldClient := server.clients.byNick[casefoldedName]
375
-	if oldClient == nil {
376
-		client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found"))
390
+	resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS))
391
+	if !resumeAllowed {
392
+		client.Send(nil, server.name, "RESUME", "ERR", oldnick, client.t("Cannot resume connection, old and new clients must have TLS"))
393
+		client.resumeDetails = nil
377 394
 		return
378 395
 	}
379 396
 
380
-	oldAccountName := oldClient.Account()
381
-	newAccountName := client.Account()
382
-
383
-	if oldAccountName == "" || newAccountName == "" || oldAccountName != newAccountName {
384
-		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"))
397
+	oldResumeToken := oldClient.ResumeToken()
398
+	if oldResumeToken == "" || !utils.SecretTokensMatch(oldResumeToken, client.resumeDetails.PresentedToken) {
399
+		client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection, invalid resume token"))
400
+		client.resumeDetails = nil
385 401
 		return
386 402
 	}
387 403
 
388
-	if !oldClient.HasMode(modes.TLS) || !client.HasMode(modes.TLS) {
389
-		client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must have TLS"))
404
+	err := server.clients.Resume(client, oldClient)
405
+	if err != nil {
406
+		client.resumeDetails = nil
407
+		client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection"))
390 408
 		return
391 409
 	}
392 410
 
393
-	// unmark the new client's nick as being occupied
394
-	server.clients.removeInternal(client)
411
+	// this is a bit racey
412
+	client.resumeDetails.ResumedAt = time.Now()
395 413
 
396
-	// send RESUMED to the reconnecting client
397
-	if timestamp == nil {
398
-		client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname())
399
-	} else {
400
-		client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString)
414
+	client.nickTimer.Touch()
415
+
416
+	// resume successful, proceed to copy client state (nickname, flags, etc.)
417
+	// after this, the server thinks that `newClient` owns the nickname
418
+
419
+	client.resumeDetails.OldClient = oldClient
420
+
421
+	// transfer monitor stuff
422
+	server.monitorManager.Resume(client, oldClient)
423
+
424
+	// record the names, not the pointers, of the channels,
425
+	// to avoid dumb annoying race conditions
426
+	channels := oldClient.Channels()
427
+	client.resumeDetails.Channels = make([]string, len(channels))
428
+	for i, channel := range channels {
429
+		client.resumeDetails.Channels[i] = channel.Name()
401 430
 	}
402 431
 
403
-	// send QUIT/RESUMED to friends
404
-	for friend := range oldClient.Friends() {
432
+	username := client.Username()
433
+	hostname := client.Hostname()
434
+
435
+	friends := make(ClientSet)
436
+	oldestLostMessage := time.Now()
437
+
438
+	// work out how much time, if any, is not covered by history buffers
439
+	for _, channel := range channels {
440
+		for _, member := range channel.Members() {
441
+			friends.Add(member)
442
+			lastDiscarded := channel.history.LastDiscarded()
443
+			if lastDiscarded.Before(oldestLostMessage) {
444
+				oldestLostMessage = lastDiscarded
445
+			}
446
+		}
447
+	}
448
+	personalHistory := oldClient.history.All()
449
+	lastDiscarded := oldClient.history.LastDiscarded()
450
+	if lastDiscarded.Before(oldestLostMessage) {
451
+		oldestLostMessage = lastDiscarded
452
+	}
453
+	for _, item := range personalHistory {
454
+		if item.Type == history.Privmsg || item.Type == history.Notice {
455
+			sender := server.clients.Get(item.Nick)
456
+			if sender != nil {
457
+				friends.Add(sender)
458
+			}
459
+		}
460
+	}
461
+
462
+	gap := lastDiscarded.Sub(timestamp)
463
+	client.resumeDetails.HistoryIncomplete = gap > 0
464
+	gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion
465
+
466
+	// send quit/resume messages to friends
467
+	for friend := range friends {
405 468
 		if friend.capabilities.Has(caps.Resume) {
406
-			if timestamp == nil {
407
-				friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname())
469
+			if timestamp.IsZero() {
470
+				friend.Send(nil, oldNickmask, "RESUMED", username, hostname)
408 471
 			} else {
409
-				friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString)
472
+				friend.Send(nil, oldNickmask, "RESUMED", username, hostname, timestampString)
410 473
 			}
411 474
 		} else {
412
-			friend.Send(nil, oldClient.NickMaskString(), "QUIT", friend.t("Client reconnected"))
475
+			if client.resumeDetails.HistoryIncomplete {
476
+				friend.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of history lost)"), gapSeconds))
477
+			} else {
478
+				friend.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected")))
479
+			}
413 480
 		}
414 481
 	}
415 482
 
416
-	// apply old client's details to new client
417
-	client.nick = oldClient.nick
418
-	client.updateNickMaskNoMutex()
419
-
420
-	rejoinChannel := func(channel *Channel) {
421
-		channel.joinPartMutex.Lock()
422
-		defer channel.joinPartMutex.Unlock()
483
+	if client.resumeDetails.HistoryIncomplete {
484
+		client.Send(nil, "RESUME", "WARN", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds))
485
+	}
423 486
 
424
-		channel.stateMutex.Lock()
425
-		client.channels[channel] = true
426
-		client.resumeDetails.SendFakeJoinsFor = append(client.resumeDetails.SendFakeJoinsFor, channel.name)
487
+	client.Send(nil, "RESUME", "SUCCESS", oldNick)
427 488
 
428
-		oldModeSet := channel.members[oldClient]
429
-		channel.members.Remove(oldClient)
430
-		channel.members[client] = oldModeSet
431
-		channel.stateMutex.Unlock()
489
+	// after we send the rest of the registration burst, we'll try rejoining channels
490
+}
432 491
 
433
-		channel.regenerateMembersCache()
492
+func (client *Client) tryResumeChannels() {
493
+	details := client.resumeDetails
494
+	if details == nil {
495
+		return
496
+	}
434 497
 
435
-		// construct fake modestring if necessary
436
-		oldModes := oldModeSet.String()
437
-		var params []string
438
-		if 0 < len(oldModes) {
439
-			params = []string{channel.name, "+" + oldModes}
440
-			for range oldModes {
441
-				params = append(params, client.nick)
442
-			}
498
+	channels := make([]*Channel, len(details.Channels))
499
+	for _, name := range details.Channels {
500
+		channel := client.server.channels.Get(name)
501
+		if channel == nil {
502
+			continue
443 503
 		}
504
+		channel.Resume(client, details.OldClient, details.Timestamp)
505
+		channels = append(channels, channel)
506
+	}
444 507
 
445
-		// send join for old clients
446
-		for member := range channel.members {
447
-			if member.capabilities.Has(caps.Resume) {
508
+	// replay direct PRIVSMG history
509
+	if !details.Timestamp.IsZero() {
510
+		now := time.Now()
511
+		nick := client.Nick()
512
+		items, complete := client.history.Between(details.Timestamp, now)
513
+		for _, item := range items {
514
+			var command string
515
+			switch item.Type {
516
+			case history.Privmsg:
517
+				command = "PRIVMSG"
518
+			case history.Notice:
519
+				command = "NOTICE"
520
+			default:
448 521
 				continue
449 522
 			}
450
-
451
-			if member.capabilities.Has(caps.ExtendedJoin) {
452
-				member.Send(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname)
453
-			} else {
454
-				member.Send(nil, client.nickMaskString, "JOIN", channel.name)
455
-			}
456
-
457
-			// send fake modestring if necessary
458
-			if 0 < len(oldModes) {
459
-				member.Send(nil, server.name, "MODE", params...)
460
-			}
523
+			client.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, command, nick, item.Message)
524
+		}
525
+		if !complete {
526
+			client.Send(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost"))
461 527
 		}
462 528
 	}
463 529
 
464
-	for channel := range oldClient.channels {
465
-		rejoinChannel(channel)
466
-	}
530
+	details.OldClient.destroy(true)
531
+}
532
+
533
+// copy applicable state from oldClient to client as part of a resume
534
+func (client *Client) copyResumeData(oldClient *Client) {
535
+	oldClient.stateMutex.RLock()
536
+	flags := oldClient.flags
537
+	history := oldClient.history
538
+	nick := oldClient.nick
539
+	nickCasefolded := oldClient.nickCasefolded
540
+	vhost := oldClient.vhost
541
+	account := oldClient.account
542
+	accountName := oldClient.accountName
543
+	oldClient.stateMutex.RUnlock()
544
+
545
+	// copy all flags, *except* TLS (in the case that the admins enabled
546
+	// resume over plaintext)
547
+	hasTLS := client.flags.HasMode(modes.TLS)
548
+	temp := modes.NewModeSet()
549
+	temp.Copy(flags)
550
+	temp.SetMode(modes.TLS, hasTLS)
551
+	client.flags.Copy(temp)
467 552
 
468
-	server.clients.byNick[oldnick] = client
553
+	client.stateMutex.Lock()
554
+	defer client.stateMutex.Unlock()
469 555
 
470
-	oldClient.destroy(true)
556
+	// reuse the old client's history buffer
557
+	client.history = history
558
+	// copy other data
559
+	client.nick = nick
560
+	client.nickCasefolded = nickCasefolded
561
+	client.vhost = vhost
562
+	client.account = account
563
+	client.accountName = accountName
564
+	client.updateNickMaskNoMutex()
471 565
 }
472 566
 
473 567
 // IdleTime returns how long this client's been idle.
@@ -501,6 +595,26 @@ func (client *Client) HasUsername() bool {
501 595
 	return client.username != "" && client.username != "*"
502 596
 }
503 597
 
598
+func (client *Client) SetNames(username, realname string) error {
599
+	_, err := CasefoldName(username)
600
+	if err != nil {
601
+		return errInvalidUsername
602
+	}
603
+
604
+	client.stateMutex.Lock()
605
+	defer client.stateMutex.Unlock()
606
+
607
+	if client.username == "" {
608
+		client.username = "~" + username
609
+	}
610
+
611
+	if client.realname == "" {
612
+		client.realname = realname
613
+	}
614
+
615
+	return nil
616
+}
617
+
504 618
 // HasRoleCapabs returns true if client has the given (role) capabilities.
505 619
 func (client *Client) HasRoleCapabs(capabs ...string) bool {
506 620
 	oper := client.Oper()
@@ -561,7 +675,7 @@ func (client *Client) Friends(capabs ...caps.Capability) ClientSet {
561 675
 func (client *Client) sendChghost(oldNickMask string, vhost string) {
562 676
 	username := client.Username()
563 677
 	for fClient := range client.Friends(caps.ChgHost) {
564
-		fClient.sendFromClientInternal("", client, oldNickMask, nil, "CHGHOST", username, vhost)
678
+		fClient.sendFromClientInternal(false, time.Time{}, "", oldNickMask, client.AccountName(), nil, "CHGHOST", username, vhost)
565 679
 	}
566 680
 }
567 681
 
@@ -711,14 +825,14 @@ func (client *Client) Quit(message string) {
711 825
 // destroy gets rid of a client, removes them from server lists etc.
712 826
 func (client *Client) destroy(beingResumed bool) {
713 827
 	// allow destroy() to execute at most once
714
-	if !beingResumed {
715
-		client.stateMutex.Lock()
716
-	}
828
+	client.stateMutex.Lock()
717 829
 	isDestroyed := client.isDestroyed
718 830
 	client.isDestroyed = true
719
-	if !beingResumed {
720
-		client.stateMutex.Unlock()
721
-	}
831
+	quitMessage := client.quitMessage
832
+	nickMaskString := client.nickMaskString
833
+	accountName := client.accountName
834
+	client.stateMutex.Unlock()
835
+
722 836
 	if isDestroyed {
723 837
 		return
724 838
 	}
@@ -758,6 +872,12 @@ func (client *Client) destroy(beingResumed bool) {
758 872
 	for _, channel := range client.Channels() {
759 873
 		if !beingResumed {
760 874
 			channel.Quit(client)
875
+			channel.history.Add(history.Item{
876
+				Type:        history.Quit,
877
+				Nick:        nickMaskString,
878
+				AccountName: accountName,
879
+				Message:     utils.MakeSplitMessage(quitMessage, true),
880
+			})
761 881
 		}
762 882
 		for _, member := range channel.Members() {
763 883
 			friends.Add(member)
@@ -791,10 +911,10 @@ func (client *Client) destroy(beingResumed bool) {
791 911
 		}
792 912
 
793 913
 		for friend := range friends {
794
-			if client.quitMessage == "" {
795
-				client.quitMessage = "Exited"
914
+			if quitMessage == "" {
915
+				quitMessage = "Exited"
796 916
 			}
797
-			friend.Send(nil, client.nickMaskString, "QUIT", client.quitMessage)
917
+			friend.Send(nil, client.nickMaskString, "QUIT", quitMessage)
798 918
 		}
799 919
 	}
800 920
 	if !client.exitedSnomaskSent {
@@ -808,43 +928,50 @@ func (client *Client) destroy(beingResumed bool) {
808 928
 
809 929
 // SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client.
810 930
 // Adds account-tag to the line as well.
811
-func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command, target string, message SplitMessage) {
812
-	if client.capabilities.Has(caps.MaxLine) {
813
-		client.SendFromClient(msgid, from, tags, command, target, message.ForMaxLine)
931
+func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags Tags, command, target string, message utils.SplitMessage) {
932
+	client.sendSplitMsgFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, target, message)
933
+}
934
+
935
+func (client *Client) sendSplitMsgFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags Tags, command, target string, message utils.SplitMessage) {
936
+	if client.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
937
+		client.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, target, message.Original)
814 938
 	} else {
815
-		for _, str := range message.For512 {
816
-			client.SendFromClient(msgid, from, tags, command, target, str)
939
+		for _, str := range message.Wrapped {
940
+			client.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, target, str)
817 941
 		}
818 942
 	}
819 943
 }
820 944
 
821 945
 // SendFromClient sends an IRC line coming from a specific client.
822 946
 // Adds account-tag to the line as well.
823
-func (client *Client) SendFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, params ...string) error {
824
-	return client.sendFromClientInternal(msgid, from, from.NickMaskString(), tags, command, params...)
947
+func (client *Client) SendFromClient(msgid string, from *Client, tags Tags, command string, params ...string) error {
948
+	return client.sendFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, params...)
949
+}
950
+
951
+// helper to add a tag to `tags` (or create a new tag set if the current one is nil)
952
+func ensureTag(tags Tags, tagName, tagValue string) (result Tags) {
953
+	if tags == nil {
954
+		result = ircmsg.MakeTags(tagName, tagValue)
955
+	} else {
956
+		result = tags
957
+		(*tags)[tagName] = ircmsg.MakeTagValue(tagValue)
958
+	}
959
+	return
825 960
 }
826 961
 
827 962
 // XXX this is a hack where we allow overriding the client's nickmask
828 963
 // this is to support CHGHOST, which requires that we send the *original* nickmask with the response
829
-func (client *Client) sendFromClientInternal(msgid string, from *Client, nickmask string, tags *map[string]ircmsg.TagValue, command string, params ...string) error {
964
+func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags Tags, command string, params ...string) error {
830 965
 	// attach account-tag
831
-	if client.capabilities.Has(caps.AccountTag) && from.LoggedIntoAccount() {
832
-		if tags == nil {
833
-			tags = ircmsg.MakeTags("account", from.AccountName())
834
-		} else {
835
-			(*tags)["account"] = ircmsg.MakeTagValue(from.AccountName())
836
-		}
966
+	if client.capabilities.Has(caps.AccountTag) && accountName != "*" {
967
+		tags = ensureTag(tags, "account", accountName)
837 968
 	}
838 969
 	// attach message-id
839 970
 	if len(msgid) > 0 && client.capabilities.Has(caps.MessageTags) {
840
-		if tags == nil {
841
-			tags = ircmsg.MakeTags("draft/msgid", msgid)
842
-		} else {
843
-			(*tags)["draft/msgid"] = ircmsg.MakeTagValue(msgid)
844
-		}
971
+		tags = ensureTag(tags, "draft/msgid", msgid)
845 972
 	}
846 973
 
847
-	return client.Send(tags, nickmask, command, params...)
974
+	return client.sendInternal(blocking, serverTime, tags, nickmask, command, params...)
848 975
 }
849 976
 
850 977
 var (
@@ -861,7 +988,7 @@ var (
861 988
 )
862 989
 
863 990
 // SendRawMessage sends a raw message to the client.
864
-func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
991
+func (client *Client) SendRawMessage(message ircmsg.IrcMessage, blocking bool) error {
865 992
 	// use dumb hack to force the last param to be a trailing param if required
866 993
 	var usedTrailingHack bool
867 994
 	if commandsThatMustUseTrailing[strings.ToUpper(message.Command)] && len(message.Params) > 0 {
@@ -883,7 +1010,11 @@ func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
883 1010
 		message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending")
884 1011
 		line, _ := message.LineBytes()
885 1012
 
886
-		client.socket.Write(line)
1013
+		if blocking {
1014
+			client.socket.BlockingWrite(line)
1015
+		} else {
1016
+			client.socket.Write(line)
1017
+		}
887 1018
 		return err
888 1019
 	}
889 1020
 
@@ -898,36 +1029,40 @@ func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
898 1029
 		client.server.logger.Debug("useroutput", client.nick, " ->", logline)
899 1030
 	}
900 1031
 
901
-	client.socket.Write(line)
902
-
903
-	return nil
1032
+	if blocking {
1033
+		return client.socket.BlockingWrite(line)
1034
+	} else {
1035
+		return client.socket.Write(line)
1036
+	}
904 1037
 }
905 1038
 
906
-// Send sends an IRC line to the client.
907
-func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error {
908
-	// attach server-time
1039
+func (client *Client) sendInternal(blocking bool, serverTime time.Time, tags Tags, prefix string, command string, params ...string) error {
1040
+	// attach server time
909 1041
 	if client.capabilities.Has(caps.ServerTime) {
910
-		t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
911
-		if tags == nil {
912
-			tags = ircmsg.MakeTags("time", t)
913
-		} else {
914
-			(*tags)["time"] = ircmsg.MakeTagValue(t)
1042
+		if serverTime.IsZero() {
1043
+			serverTime = time.Now()
915 1044
 		}
1045
+		tags = ensureTag(tags, "time", serverTime.UTC().Format(IRCv3TimestampFormat))
916 1046
 	}
917 1047
 
918 1048
 	// send out the message
919 1049
 	message := ircmsg.MakeMessage(tags, prefix, command, params...)
920
-	client.SendRawMessage(message)
1050
+	client.SendRawMessage(message, blocking)
921 1051
 	return nil
922 1052
 }
923 1053
 
1054
+// Send sends an IRC line to the client.
1055
+func (client *Client) Send(tags Tags, prefix string, command string, params ...string) error {
1056
+	return client.sendInternal(false, time.Time{}, tags, prefix, command, params...)
1057
+}
1058
+
924 1059
 // Notice sends the client a notice from the server.
925 1060
 func (client *Client) Notice(text string) {
926 1061
 	limit := 400
927 1062
 	if client.capabilities.Has(caps.MaxLine) {
928 1063
 		limit = client.server.Limits().LineLen.Rest - 110
929 1064
 	}
930
-	lines := wordWrap(text, limit)
1065
+	lines := utils.WordWrap(text, limit)
931 1066
 
932 1067
 	// force blank lines to be sent if we receive them
933 1068
 	if len(lines) == 0 {
@@ -950,3 +1085,20 @@ func (client *Client) removeChannel(channel *Channel) {
950 1085
 	delete(client.channels, channel)
951 1086
 	client.stateMutex.Unlock()
952 1087
 }
1088
+
1089
+// Ensures the client has a cryptographically secure resume token, and returns
1090
+// its value. An error is returned if a token was previously assigned.
1091
+func (client *Client) generateResumeToken() (token string, err error) {
1092
+	newToken := utils.GenerateSecretToken()
1093
+
1094
+	client.stateMutex.Lock()
1095
+	defer client.stateMutex.Unlock()
1096
+
1097
+	if client.resumeToken == "" {
1098
+		client.resumeToken = newToken
1099
+	} else {
1100
+		err = errResumeTokenAlreadySet
1101
+	}
1102
+
1103
+	return client.resumeToken, err
1104
+}

+ 24
- 3
irc/client_lookup_set.go View File

@@ -63,17 +63,17 @@ func (clients *ClientManager) Get(nick string) *Client {
63 63
 	return nil
64 64
 }
65 65
 
66
-func (clients *ClientManager) removeInternal(client *Client) (removed bool) {
66
+func (clients *ClientManager) removeInternal(client *Client) (err error) {
67 67
 	// requires holding the writable Lock()
68 68
 	oldcfnick := client.NickCasefolded()
69 69
 	currentEntry, present := clients.byNick[oldcfnick]
70 70
 	if present {
71 71
 		if currentEntry == client {
72 72
 			delete(clients.byNick, oldcfnick)
73
-			removed = true
74 73
 		} else {
75 74
 			// this shouldn't happen, but we can ignore it
76 75
 			client.server.logger.Warning("internal", fmt.Sprintf("clients for nick %s out of sync", oldcfnick))
76
+			err = errNickMissing
77 77
 		}
78 78
 	}
79 79
 	return
@@ -87,7 +87,28 @@ func (clients *ClientManager) Remove(client *Client) error {
87 87
 	if !client.HasNick() {
88 88
 		return errNickMissing
89 89
 	}
90
-	clients.removeInternal(client)
90
+	return clients.removeInternal(client)
91
+}
92
+
93
+// Resume atomically replaces `oldClient` with `newClient`, updating
94
+// newClient's data to match. It is the caller's responsibility first
95
+// to verify that the resume is allowed, and then later to call oldClient.destroy().
96
+func (clients *ClientManager) Resume(newClient, oldClient *Client) (err error) {
97
+	clients.Lock()
98
+	defer clients.Unlock()
99
+
100
+	// atomically grant the new client the old nick
101
+	err = clients.removeInternal(oldClient)
102
+	if err != nil {
103
+		// oldClient no longer owns its nick, fail out
104
+		return err
105
+	}
106
+	// nick has been reclaimed, grant it to the new client
107
+	clients.removeInternal(newClient)
108
+	clients.byNick[oldClient.NickCasefolded()] = newClient
109
+
110
+	newClient.copyResumeData(oldClient)
111
+
91 112
 	return nil
92 113
 }
93 114
 

+ 1
- 1
irc/commands.go View File

@@ -222,7 +222,7 @@ func init() {
222 222
 		"RESUME": {
223 223
 			handler:      resumeHandler,
224 224
 			usablePreReg: true,
225
-			minParams:    1,
225
+			minParams:    2,
226 226
 		},
227 227
 		"SAJOIN": {
228 228
 			handler:   sajoinHandler,

+ 32
- 17
irc/config.go View File

@@ -208,23 +208,24 @@ type Config struct {
208 208
 	}
209 209
 
210 210
 	Server struct {
211
-		Password            string
212
-		passwordBytes       []byte
213
-		Name                string
214
-		nameCasefolded      string
215
-		Listen              []string
216
-		UnixBindMode        os.FileMode                 `yaml:"unix-bind-mode"`
217
-		TLSListeners        map[string]*TLSListenConfig `yaml:"tls-listeners"`
218
-		STS                 STSConfig
219
-		CheckIdent          bool `yaml:"check-ident"`
220
-		MOTD                string
221
-		MOTDFormatting      bool           `yaml:"motd-formatting"`
222
-		ProxyAllowedFrom    []string       `yaml:"proxy-allowed-from"`
223
-		WebIRC              []webircConfig `yaml:"webirc"`
224
-		MaxSendQString      string         `yaml:"max-sendq"`
225
-		MaxSendQBytes       int
226
-		ConnectionLimiter   connection_limits.LimiterConfig   `yaml:"connection-limits"`
227
-		ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"`
211
+		Password             string
212
+		passwordBytes        []byte
213
+		Name                 string
214
+		nameCasefolded       string
215
+		Listen               []string
216
+		UnixBindMode         os.FileMode                 `yaml:"unix-bind-mode"`
217
+		TLSListeners         map[string]*TLSListenConfig `yaml:"tls-listeners"`
218
+		STS                  STSConfig
219
+		CheckIdent           bool `yaml:"check-ident"`
220
+		MOTD                 string
221
+		MOTDFormatting       bool           `yaml:"motd-formatting"`
222
+		ProxyAllowedFrom     []string       `yaml:"proxy-allowed-from"`
223
+		WebIRC               []webircConfig `yaml:"webirc"`
224
+		MaxSendQString       string         `yaml:"max-sendq"`
225
+		MaxSendQBytes        int
226
+		AllowPlaintextResume bool                              `yaml:"allow-plaintext-resume"`
227
+		ConnectionLimiter    connection_limits.LimiterConfig   `yaml:"connection-limits"`
228
+		ConnectionThrottler  connection_limits.ThrottlerConfig `yaml:"connection-throttling"`
228 229
 	}
229 230
 
230 231
 	Languages struct {
@@ -266,6 +267,12 @@ type Config struct {
266 267
 
267 268
 	Fakelag FakelagConfig
268 269
 
270
+	History struct {
271
+		Enabled       bool
272
+		ChannelLength int `yaml:"channel-length"`
273
+		ClientLength  int `yaml:"client-length"`
274
+	}
275
+
269 276
 	Filename string
270 277
 }
271 278
 
@@ -712,5 +719,13 @@ func LoadConfig(filename string) (config *Config, err error) {
712 719
 		config.Accounts.Registration.BcryptCost = passwd.DefaultCost
713 720
 	}
714 721
 
722
+	// in the current implementation, we disable history by creating a history buffer
723
+	// with zero capacity. but the `enabled` config option MUST be respected regardless
724
+	// of this detail
725
+	if !config.History.Enabled {
726
+		config.History.ChannelLength = 0
727
+		config.History.ClientLength = 0
728
+	}
729
+
715 730
 	return config, nil
716 731
 }

+ 2
- 0
irc/errors.go View File

@@ -38,6 +38,8 @@ var (
38 38
 	errRenamePrivsNeeded              = errors.New("Only chanops can rename channels")
39 39
 	errInsufficientPrivs              = errors.New("Insufficient privileges")
40 40
 	errSaslFail                       = errors.New("SASL failed")
41
+	errResumeTokenAlreadySet          = errors.New("Client was already assigned a resume token")
42
+	errInvalidUsername                = errors.New("Invalid username")
41 43
 )
42 44
 
43 45
 // Socket Errors

+ 6
- 0
irc/getters.go View File

@@ -102,6 +102,12 @@ func (client *Client) Realname() string {
102 102
 	return client.realname
103 103
 }
104 104
 
105
+func (client *Client) ResumeToken() string {
106
+	client.stateMutex.RLock()
107
+	defer client.stateMutex.RUnlock()
108
+	return client.resumeToken
109
+}
110
+
105 111
 func (client *Client) Oper() *Oper {
106 112
 	client.stateMutex.RLock()
107 113
 	defer client.stateMutex.RUnlock()

+ 39
- 30
irc/handlers.go View File

@@ -26,6 +26,7 @@ import (
26 26
 	"github.com/goshuirc/irc-go/ircmsg"
27 27
 	"github.com/oragono/oragono/irc/caps"
28 28
 	"github.com/oragono/oragono/irc/custime"
29
+	"github.com/oragono/oragono/irc/history"
29 30
 	"github.com/oragono/oragono/irc/modes"
30 31
 	"github.com/oragono/oragono/irc/sno"
31 32
 	"github.com/oragono/oragono/irc/utils"
@@ -483,6 +484,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
483 484
 		client.capabilities.Union(capabilities)
484 485
 		rb.Add(nil, server.name, "CAP", client.nick, "ACK", capString)
485 486
 
487
+		// if this is the first time the client is requesting a resume token,
488
+		// send it to them
489
+		if capabilities.Has(caps.Resume) {
490
+			token, err := client.generateResumeToken()
491
+			if err == nil {
492
+				rb.Add(nil, server.name, "RESUME", "TOKEN", token)
493
+			}
494
+		}
495
+
486 496
 	case "END":
487 497
 		if !client.Registered() {
488 498
 			client.capState = caps.NegotiatedState
@@ -1648,7 +1658,7 @@ func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
1648 1658
 	message := msg.Params[1]
1649 1659
 
1650 1660
 	// split privmsg
1651
-	splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine))
1661
+	splitMsg := utils.MakeSplitMessage(message, !client.capabilities.Has(caps.MaxLine))
1652 1662
 
1653 1663
 	for i, targetString := range targets {
1654 1664
 		// max of four targets per privmsg
@@ -1699,6 +1709,14 @@ func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
1699 1709
 			if client.capabilities.Has(caps.EchoMessage) {
1700 1710
 				rb.AddSplitMessageFromClient(msgid, client, clientOnlyTags, "NOTICE", user.nick, splitMsg)
1701 1711
 			}
1712
+
1713
+			user.history.Add(history.Item{
1714
+				Type:        history.Notice,
1715
+				Msgid:       msgid,
1716
+				Message:     splitMsg,
1717
+				Nick:        client.NickMaskString(),
1718
+				AccountName: client.AccountName(),
1719
+			})
1702 1720
 		}
1703 1721
 	}
1704 1722
 	return false
@@ -1848,7 +1866,7 @@ func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
1848 1866
 	message := msg.Params[1]
1849 1867
 
1850 1868
 	// split privmsg
1851
-	splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine))
1869
+	splitMsg := utils.MakeSplitMessage(message, !client.capabilities.Has(caps.MaxLine))
1852 1870
 
1853 1871
 	for i, targetString := range targets {
1854 1872
 		// max of four targets per privmsg
@@ -1905,6 +1923,14 @@ func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
1905 1923
 				//TODO(dan): possibly implement cooldown of away notifications to users
1906 1924
 				rb.Add(nil, server.name, RPL_AWAY, user.nick, user.awayMessage)
1907 1925
 			}
1926
+
1927
+			user.history.Add(history.Item{
1928
+				Type:        history.Privmsg,
1929
+				Msgid:       msgid,
1930
+				Message:     splitMsg,
1931
+				Nick:        client.NickMaskString(),
1932
+				AccountName: client.AccountName(),
1933
+			})
1908 1934
 		}
1909 1935
 	}
1910 1936
 	return false
@@ -2018,33 +2044,30 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
2018 2044
 	return false
2019 2045
 }
2020 2046
 
2021
-// RESUME <oldnick> [timestamp]
2047
+// RESUME <oldnick> <token> [timestamp]
2022 2048
 func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2023 2049
 	oldnick := msg.Params[0]
2024
-
2025
-	if strings.Contains(oldnick, " ") {
2026
-		rb.Add(nil, server.name, ERR_CANNOT_RESUME, "*", client.t("Cannot resume connection, old nickname contains spaces"))
2027
-		return false
2028
-	}
2050
+	token := msg.Params[1]
2029 2051
 
2030 2052
 	if client.Registered() {
2031 2053
 		rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, connection registration has already been completed"))
2032 2054
 		return false
2033 2055
 	}
2034 2056
 
2035
-	var timestamp *time.Time
2036
-	if 1 < len(msg.Params) {
2037
-		ts, err := time.Parse("2006-01-02T15:04:05.999Z", msg.Params[1])
2057
+	var timestamp time.Time
2058
+	if 2 < len(msg.Params) {
2059
+		ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[2])
2038 2060
 		if err == nil {
2039
-			timestamp = &ts
2061
+			timestamp = ts
2040 2062
 		} else {
2041 2063
 			rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it"))
2042 2064
 		}
2043 2065
 	}
2044 2066
 
2045 2067
 	client.resumeDetails = &ResumeDetails{
2046
-		OldNick:   oldnick,
2047
-		Timestamp: timestamp,
2068
+		OldNick:        oldnick,
2069
+		Timestamp:      timestamp,
2070
+		PresentedToken: token,
2048 2071
 	}
2049 2072
 
2050 2073
 	return false
@@ -2280,26 +2303,12 @@ func userHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
2280 2303
 		return false
2281 2304
 	}
2282 2305
 
2283
-	if client.username != "" && client.realname != "" {
2284
-		return false
2285
-	}
2286
-
2287
-	// confirm that username is valid
2288
-	//
2289
-	_, err := CasefoldName(msg.Params[0])
2290
-	if err != nil {
2306
+	err := client.SetNames(msg.Params[0], msg.Params[3])
2307
+	if err == errInvalidUsername {
2291 2308
 		rb.Add(nil, "", "ERROR", client.t("Malformed username"))
2292 2309
 		return true
2293 2310
 	}
2294 2311
 
2295
-	if !client.HasUsername() {
2296
-		client.username = "~" + msg.Params[0]
2297
-		// don't bother updating nickmask here, it's not valid anyway
2298
-	}
2299
-	if client.realname == "" {
2300
-		client.realname = msg.Params[3]
2301
-	}
2302
-
2303 2312
 	return false
2304 2313
 }
2305 2314
 

+ 249
- 0
irc/history/history.go View File

@@ -0,0 +1,249 @@
1
+// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package history
5
+
6
+import (
7
+	"github.com/oragono/oragono/irc/utils"
8
+	"sync"
9
+	"sync/atomic"
10
+	"time"
11
+)
12
+
13
+type ItemType uint
14
+
15
+const (
16
+	uninitializedItem ItemType = iota
17
+	Privmsg
18
+	Notice
19
+	Join
20
+	Part
21
+	Kick
22
+	Quit
23
+	Mode
24
+)
25
+
26
+// Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data
27
+type Item struct {
28
+	Type ItemType
29
+	Time time.Time
30
+
31
+	Nick string
32
+	// this is the uncasefolded account name, if there's no account it should be set to "*"
33
+	AccountName string
34
+	Message     utils.SplitMessage
35
+	Msgid       string
36
+}
37
+
38
+// Buffer is a ring buffer holding message/event history for a channel or user
39
+type Buffer struct {
40
+	sync.RWMutex
41
+
42
+	// ring buffer, see irc/whowas.go for conventions
43
+	buffer []Item
44
+	start  int
45
+	end    int
46
+
47
+	lastDiscarded time.Time
48
+
49
+	enabled uint32
50
+}
51
+
52
+func NewHistoryBuffer(size int) (result *Buffer) {
53
+	result = new(Buffer)
54
+	result.Initialize(size)
55
+	return
56
+}
57
+
58
+func (hist *Buffer) Initialize(size int) {
59
+	hist.buffer = make([]Item, size)
60
+	hist.start = -1
61
+	hist.end = -1
62
+
63
+	hist.setEnabled(size)
64
+}
65
+
66
+func (hist *Buffer) setEnabled(size int) {
67
+	var enabled uint32
68
+	if size != 0 {
69
+		enabled = 1
70
+	}
71
+	atomic.StoreUint32(&hist.enabled, enabled)
72
+}
73
+
74
+// Enabled returns whether the buffer is currently storing messages
75
+// (a disabled buffer blackholes everything it sees)
76
+func (list *Buffer) Enabled() bool {
77
+	return atomic.LoadUint32(&list.enabled) != 0
78
+}
79
+
80
+// Add adds a history item to the buffer
81
+func (list *Buffer) Add(item Item) {
82
+	// fast path without a lock acquisition for when we are not storing history
83
+	if !list.Enabled() {
84
+		return
85
+	}
86
+
87
+	if item.Time.IsZero() {
88
+		item.Time = time.Now()
89
+	}
90
+
91
+	list.Lock()
92
+	defer list.Unlock()
93
+
94
+	var pos int
95
+	if list.start == -1 { // empty
96
+		pos = 0
97
+		list.start = 0
98
+		list.end = 1 % len(list.buffer)
99
+	} else if list.start != list.end { // partially full
100
+		pos = list.end
101
+		list.end = (list.end + 1) % len(list.buffer)
102
+	} else if list.start == list.end { // full
103
+		pos = list.end
104
+		list.end = (list.end + 1) % len(list.buffer)
105
+		list.start = list.end // advance start as well, overwriting first entry
106
+		// record the timestamp of the overwritten item
107
+		if list.lastDiscarded.Before(list.buffer[pos].Time) {
108
+			list.lastDiscarded = list.buffer[pos].Time
109
+		}
110
+	}
111
+
112
+	list.buffer[pos] = item
113
+}
114
+
115
+// Between returns all history items with a time `after` <= time <= `before`,
116
+// with an indication of whether the results are complete or are missing items
117
+// because some of that period was discarded. A zero value of `before` is considered
118
+// higher than all other times.
119
+func (list *Buffer) Between(after, before time.Time) (results []Item, complete bool) {
120
+	if !list.Enabled() {
121
+		return
122
+	}
123
+
124
+	list.RLock()
125
+	defer list.RUnlock()
126
+
127
+	complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded)
128
+
129
+	if list.start == -1 {
130
+		return
131
+	}
132
+
133
+	satisfies := func(itime time.Time) bool {
134
+		return (after.IsZero() || itime.After(after)) && (before.IsZero() || itime.Before(before))
135
+	}
136
+
137
+	// TODO: if we can guarantee that the insertion order is also the monotonic clock order,
138
+	// then this can do a single allocation and use binary search and 1-2 copy calls
139
+
140
+	pos := list.prev(list.end)
141
+	for {
142
+		if satisfies(list.buffer[pos].Time) {
143
+			results = append(results, list.buffer[pos])
144
+		}
145
+		if pos == list.start {
146
+			break
147
+		}
148
+		pos = list.prev(pos)
149
+	}
150
+
151
+	// reverse the results
152
+	for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
153
+		results[i], results[j] = results[j], results[i]
154
+	}
155
+	return
156
+}
157
+
158
+// All returns all available history items as a slice
159
+func (list *Buffer) All() (results []Item) {
160
+	list.RLock()
161
+	defer list.RUnlock()
162
+
163
+	if list.start == -1 {
164
+		return
165
+	}
166
+	results = make([]Item, list.length())
167
+	if list.start < list.end {
168
+		copy(results, list.buffer[list.start:list.end])
169
+	} else {
170
+		initialSegment := copy(results, list.buffer[list.start:])
171
+		copy(results[initialSegment:], list.buffer[:list.end])
172
+	}
173
+	return
174
+}
175
+
176
+// LastDiscarded returns the latest time of any entry that was evicted
177
+// from the ring buffer.
178
+func (list *Buffer) LastDiscarded() time.Time {
179
+	list.RLock()
180
+	defer list.RUnlock()
181
+
182
+	return list.lastDiscarded
183
+}
184
+
185
+func (list *Buffer) prev(index int) int {
186
+	switch index {
187
+	case 0:
188
+		return len(list.buffer) - 1
189
+	default:
190
+		return index - 1
191
+	}
192
+}
193
+
194
+// Resize shrinks or expands the buffer
195
+func (list *Buffer) Resize(size int) {
196
+	newbuffer := make([]Item, size)
197
+	list.Lock()
198
+	defer list.Unlock()
199
+
200
+	list.setEnabled(size)
201
+
202
+	if list.start == -1 {
203
+		// indices are already correct and nothing needs to be copied
204
+	} else if size == 0 {
205
+		// this is now the empty list
206
+		list.start = -1
207
+		list.end = -1
208
+	} else {
209
+		currentLength := list.length()
210
+		start := list.start
211
+		end := list.end
212
+		// if we're truncating, keep the latest entries, not the earliest
213
+		if size < currentLength {
214
+			start = list.end - size
215
+			if start < 0 {
216
+				start += len(list.buffer)
217
+			}
218
+			// update lastDiscarded for discarded entries
219
+			for i := list.start; i != start; i = (i + 1) % len(list.buffer) {
220
+				if list.lastDiscarded.Before(list.buffer[i].Time) {
221
+					list.lastDiscarded = list.buffer[i].Time
222
+				}
223
+			}
224
+		}
225
+		if start < end {
226
+			copied := copy(newbuffer, list.buffer[start:end])
227
+			list.start = 0
228
+			list.end = copied % size
229
+		} else {
230
+			lenInitial := len(list.buffer) - start
231
+			copied := copy(newbuffer, list.buffer[start:])
232
+			copied += copy(newbuffer[lenInitial:], list.buffer[:end])
233
+			list.start = 0
234
+			list.end = copied % size
235
+		}
236
+	}
237
+
238
+	list.buffer = newbuffer
239
+}
240
+
241
+func (hist *Buffer) length() int {
242
+	if hist.start == -1 {
243
+		return 0
244
+	} else if hist.start < hist.end {
245
+		return hist.end - hist.start
246
+	} else {
247
+		return len(hist.buffer) - (hist.start - hist.end)
248
+	}
249
+}

+ 156
- 0
irc/history/history_test.go View File

@@ -0,0 +1,156 @@
1
+// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package history
5
+
6
+import (
7
+	"reflect"
8
+	"testing"
9
+	"time"
10
+)
11
+
12
+const (
13
+	timeFormat = "2006-01-02 15:04:05Z"
14
+)
15
+
16
+func TestEmptyBuffer(t *testing.T) {
17
+	pastTime := easyParse(timeFormat)
18
+
19
+	buf := NewHistoryBuffer(0)
20
+	if buf.Enabled() {
21
+		t.Error("the buffer of size 0 must be considered disabled")
22
+	}
23
+
24
+	buf.Add(Item{
25
+		Nick: "testnick",
26
+	})
27
+
28
+	since, complete := buf.Between(pastTime, time.Now())
29
+	if len(since) != 0 {
30
+		t.Error("shouldn't be able to add to disabled buf")
31
+	}
32
+	if complete {
33
+		t.Error("the empty/disabled buffer should report results as incomplete")
34
+	}
35
+
36
+	buf.Resize(1)
37
+	if !buf.Enabled() {
38
+		t.Error("the buffer of size 1 must be considered enabled")
39
+	}
40
+	since, complete = buf.Between(pastTime, time.Now())
41
+	assertEqual(complete, true, t)
42
+	assertEqual(len(since), 0, t)
43
+	buf.Add(Item{
44
+		Nick: "testnick",
45
+	})
46
+	since, complete = buf.Between(pastTime, time.Now())
47
+	if len(since) != 1 {
48
+		t.Error("should be able to store items in a nonempty buffer")
49
+	}
50
+	if !complete {
51
+		t.Error("results should be complete")
52
+	}
53
+	if since[0].Nick != "testnick" {
54
+		t.Error("retrived junk data")
55
+	}
56
+
57
+	buf.Add(Item{
58
+		Nick: "testnick2",
59
+	})
60
+	since, complete = buf.Between(pastTime, time.Now())
61
+	if len(since) != 1 {
62
+		t.Error("expect exactly 1 item")
63
+	}
64
+	if complete {
65
+		t.Error("results must be marked incomplete")
66
+	}
67
+	if since[0].Nick != "testnick2" {
68
+		t.Error("retrieved junk data")
69
+	}
70
+	assertEqual(toNicks(buf.All()), []string{"testnick2"}, t)
71
+}
72
+
73
+func toNicks(items []Item) (result []string) {
74
+	result = make([]string, len(items))
75
+	for i, item := range items {
76
+		result[i] = item.Nick
77
+	}
78
+	return
79
+}
80
+
81
+func easyParse(timestamp string) time.Time {
82
+	result, err := time.Parse(timeFormat, timestamp)
83
+	if err != nil {
84
+		panic(err)
85
+	}
86
+	return result
87
+}
88
+
89
+func assertEqual(supplied, expected interface{}, t *testing.T) {
90
+	if !reflect.DeepEqual(supplied, expected) {
91
+		t.Errorf("expected %v but got %v", expected, supplied)
92
+	}
93
+}
94
+
95
+func TestBuffer(t *testing.T) {
96
+	start := easyParse("2006-01-01 00:00:00Z")
97
+
98
+	buf := NewHistoryBuffer(3)
99
+	buf.Add(Item{
100
+		Nick: "testnick0",
101
+		Time: easyParse("2006-01-01 15:04:05Z"),
102
+	})
103
+
104
+	buf.Add(Item{
105
+		Nick: "testnick1",
106
+		Time: easyParse("2006-01-02 15:04:05Z"),
107
+	})
108
+
109
+	buf.Add(Item{
110
+		Nick: "testnick2",
111
+		Time: easyParse("2006-01-03 15:04:05Z"),
112
+	})
113
+
114
+	since, complete := buf.Between(start, time.Now())
115
+	assertEqual(complete, true, t)
116
+	assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
117
+
118
+	// add another item, evicting the first
119
+	buf.Add(Item{
120
+		Nick: "testnick3",
121
+		Time: easyParse("2006-01-04 15:04:05Z"),
122
+	})
123
+	since, complete = buf.Between(start, time.Now())
124
+	assertEqual(complete, false, t)
125
+	assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
126
+	// now exclude the time of the discarded entry; results should be complete again
127
+	since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now())
128
+	assertEqual(complete, true, t)
129
+	assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
130
+	since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), easyParse("2006-01-03 00:00:00Z"))
131
+	assertEqual(complete, true, t)
132
+	assertEqual(toNicks(since), []string{"testnick1"}, t)
133
+
134
+	// shrink the buffer, cutting off testnick1
135
+	buf.Resize(2)
136
+	since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now())
137
+	assertEqual(complete, false, t)
138
+	assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
139
+
140
+	buf.Resize(5)
141
+	buf.Add(Item{
142
+		Nick: "testnick4",
143
+		Time: easyParse("2006-01-05 15:04:05Z"),
144
+	})
145
+	buf.Add(Item{
146
+		Nick: "testnick5",
147
+		Time: easyParse("2006-01-06 15:04:05Z"),
148
+	})
149
+	buf.Add(Item{
150
+		Nick: "testnick6",
151
+		Time: easyParse("2006-01-07 15:04:05Z"),
152
+	})
153
+	since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now())
154
+	assertEqual(complete, true, t)
155
+	assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t)
156
+}

+ 5
- 0
irc/modes/modes.go View File

@@ -353,6 +353,11 @@ func (set *ModeSet) SetMode(mode Mode, on bool) (applied bool) {
353 353
 	return utils.BitsetSet(set[:], uint(mode)-minMode, on)
354 354
 }
355 355
 
356
+// copy the contents of another modeset on top of this one
357
+func (set *ModeSet) Copy(other *ModeSet) {
358
+	utils.BitsetCopy(set[:], other[:])
359
+}
360
+
356 361
 // return the modes in the set as a slice
357 362
 func (set *ModeSet) AllModes() (result []Mode) {
358 363
 	if set == nil {

+ 18
- 0
irc/monitor.go View File

@@ -81,6 +81,24 @@ func (manager *MonitorManager) Remove(client *Client, nick string) error {
81 81
 	return nil
82 82
 }
83 83
 
84
+func (manager *MonitorManager) Resume(newClient, oldClient *Client) error {
85
+	manager.Lock()
86
+	defer manager.Unlock()
87
+
88
+	// newClient is now watching everyone oldClient was watching
89
+	oldTargets := manager.watching[oldClient]
90
+	delete(manager.watching, oldClient)
91
+	manager.watching[newClient] = oldTargets
92
+
93
+	// update watchedby as well
94
+	for watchedNick := range oldTargets {
95
+		delete(manager.watchedby[watchedNick], oldClient)
96
+		manager.watchedby[watchedNick][newClient] = true
97
+	}
98
+
99
+	return nil
100
+}
101
+
84 102
 // RemoveAll unregisters `client` from receiving notifications about *all* nicks.
85 103
 func (manager *MonitorManager) RemoveAll(client *Client) {
86 104
 	manager.Lock()

+ 2
- 1
irc/nickname.go View File

@@ -16,7 +16,8 @@ import (
16 16
 
17 17
 var (
18 18
 	restrictedNicknames = map[string]bool{
19
-		"=scene=": true, // used for rp commands
19
+		"=scene=":  true, // used for rp commands
20
+		"HistServ": true, // TODO(slingamn) this should become a real service
20 21
 	}
21 22
 )
22 23
 

+ 13
- 7
irc/responsebuffer.go View File

@@ -8,6 +8,7 @@ import (
8 8
 
9 9
 	"github.com/goshuirc/irc-go/ircmsg"
10 10
 	"github.com/oragono/oragono/irc/caps"
11
+	"github.com/oragono/oragono/irc/utils"
11 12
 )
12 13
 
13 14
 // ResponseBuffer - put simply - buffers messages and then outputs them to a given client.
@@ -19,6 +20,7 @@ type ResponseBuffer struct {
19 20
 	Label    string
20 21
 	target   *Client
21 22
 	messages []ircmsg.IrcMessage
23
+	blocking bool
22 24
 }
23 25
 
24 26
 // GetLabel returns the label from the given message.
@@ -33,6 +35,10 @@ func NewResponseBuffer(target *Client) *ResponseBuffer {
33 35
 	}
34 36
 }
35 37
 
38
+func (rb *ResponseBuffer) SetBlocking(blocking bool) {
39
+	rb.blocking = blocking
40
+}
41
+
36 42
 // Add adds a standard new message to our queue.
37 43
 func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) {
38 44
 	message := ircmsg.MakeMessage(tags, prefix, command, params...)
@@ -63,11 +69,11 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, from *Client, tags *map[st
63 69
 }
64 70
 
65 71
 // AddSplitMessageFromClient adds a new split message from a specific client to our queue.
66
-func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message SplitMessage) {
67
-	if rb.target.capabilities.Has(caps.MaxLine) {
68
-		rb.AddFromClient(msgid, from, tags, command, target, message.ForMaxLine)
72
+func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message utils.SplitMessage) {
73
+	if rb.target.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
74
+		rb.AddFromClient(msgid, from, tags, command, target, message.Original)
69 75
 	} else {
70
-		for _, str := range message.For512 {
76
+		for _, str := range message.Wrapped {
71 77
 			rb.AddFromClient(msgid, from, tags, command, target, str)
72 78
 		}
73 79
 	}
@@ -103,7 +109,7 @@ func (rb *ResponseBuffer) Send() error {
103 109
 	for _, message := range rb.messages {
104 110
 		// attach server-time if needed
105 111
 		if rb.target.capabilities.Has(caps.ServerTime) {
106
-			t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
112
+			t := time.Now().UTC().Format(IRCv3TimestampFormat)
107 113
 			message.Tags["time"] = ircmsg.MakeTagValue(t)
108 114
 		}
109 115
 
@@ -113,7 +119,7 @@ func (rb *ResponseBuffer) Send() error {
113 119
 		}
114 120
 
115 121
 		// send message out
116
-		rb.target.SendRawMessage(message)
122
+		rb.target.SendRawMessage(message, rb.blocking)
117 123
 	}
118 124
 
119 125
 	// end batch if required
@@ -122,7 +128,7 @@ func (rb *ResponseBuffer) Send() error {
122 128
 	}
123 129
 
124 130
 	// clear out any existing messages
125
-	rb.messages = []ircmsg.IrcMessage{}
131
+	rb.messages = rb.messages[:0]
126 132
 
127 133
 	return nil
128 134
 }

+ 17
- 105
irc/server.go View File

@@ -430,6 +430,8 @@ func (server *Server) tryRegister(c *Client) {
430 430
 	// continue registration
431 431
 	server.logger.Debug("localconnect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", c.nick, c.username, c.realname))
432 432
 	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))
433
+
434
+	// "register"; this includes the initial phase of session resumption
433 435
 	c.Register()
434 436
 
435 437
 	// send welcome text
@@ -455,41 +457,7 @@ func (server *Server) tryRegister(c *Client) {
455 457
 	}
456 458
 
457 459
 	// if resumed, send fake channel joins
458
-	if c.resumeDetails != nil {
459
-		for _, name := range c.resumeDetails.SendFakeJoinsFor {
460
-			channel := server.channels.Get(name)
461
-			if channel == nil {
462
-				continue
463
-			}
464
-
465
-			if c.capabilities.Has(caps.ExtendedJoin) {
466
-				c.Send(nil, c.nickMaskString, "JOIN", channel.name, c.AccountName(), c.realname)
467
-			} else {
468
-				c.Send(nil, c.nickMaskString, "JOIN", channel.name)
469
-			}
470
-			// reuse the last rb
471
-			channel.SendTopic(c, rb)
472
-			channel.Names(c, rb)
473
-			rb.Send()
474
-
475
-			// construct and send fake modestring if necessary
476
-			c.stateMutex.RLock()
477
-			myModes := channel.members[c]
478
-			c.stateMutex.RUnlock()
479
-			if myModes == nil {
480
-				continue
481
-			}
482
-			oldModes := myModes.String()
483
-			if 0 < len(oldModes) {
484
-				params := []string{channel.name, "+" + oldModes}
485
-				for range oldModes {
486
-					params = append(params, c.nick)
487
-				}
488
-
489
-				c.Send(nil, server.name, "MODE", params...)
490
-			}
491
-		}
492
-	}
460
+	c.tryResumeChannels()
493 461
 }
494 462
 
495 463
 // 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) {
519 487
 	rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
520 488
 }
521 489
 
522
-// wordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
523
-func wordWrap(text string, lineWidth int) []string {
524
-	var lines []string
525
-	var cacheLine, cacheWord string
526
-
527
-	for _, char := range text {
528
-		if char == '\r' {
529
-			continue
530
-		} else if char == '\n' {
531
-			cacheLine += cacheWord
532
-			lines = append(lines, cacheLine)
533
-			cacheWord = ""
534
-			cacheLine = ""
535
-		} else if (char == ' ' || char == '-') && len(cacheLine)+len(cacheWord)+1 < lineWidth {
536
-			// natural word boundary
537
-			cacheLine += cacheWord + string(char)
538
-			cacheWord = ""
539
-		} else if lineWidth <= len(cacheLine)+len(cacheWord)+1 {
540
-			// time to wrap to next line
541
-			if len(cacheLine) < (lineWidth / 2) {
542
-				// this word takes up more than half a line... just split in the middle of the word
543
-				cacheLine += cacheWord + string(char)
544
-				cacheWord = ""
545
-			} else {
546
-				cacheWord += string(char)
547
-			}
548
-			lines = append(lines, cacheLine)
549
-			cacheLine = ""
550
-		} else {
551
-			// normal character
552
-			cacheWord += string(char)
553
-		}
554
-	}
555
-	if 0 < len(cacheWord) {
556
-		cacheLine += cacheWord
557
-	}
558
-	if 0 < len(cacheLine) {
559
-		lines = append(lines, cacheLine)
560
-	}
561
-
562
-	return lines
563
-}
564
-
565
-// SplitMessage represents a message that's been split for sending.
566
-type SplitMessage struct {
567
-	For512     []string
568
-	ForMaxLine string
569
-}
570
-
571
-func (server *Server) splitMessage(original string, origIs512 bool) SplitMessage {
572
-	var newSplit SplitMessage
573
-
574
-	newSplit.ForMaxLine = original
575
-
576
-	if !origIs512 {
577
-		newSplit.For512 = wordWrap(original, 400)
578
-	} else {
579
-		newSplit.For512 = []string{original}
580
-	}
581
-
582
-	return newSplit
583
-}
584
-
585 490
 // WhoisChannelsNames returns the common channel names between two users.
586 491
 func (client *Client) WhoisChannelsNames(target *Client) []string {
587 492
 	isMultiPrefix := client.capabilities.Has(caps.MultiPrefix)
@@ -817,6 +722,20 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
817 722
 		updatedCaps.Add(caps.STS)
818 723
 	}
819 724
 
725
+	// resize history buffers as needed
726
+	if oldConfig != nil {
727
+		if oldConfig.History.ChannelLength != config.History.ChannelLength {
728
+			for _, channel := range server.channels.Channels() {
729
+				channel.history.Resize(config.History.ChannelLength)
730
+			}
731
+		}
732
+		if oldConfig.History.ClientLength != config.History.ClientLength {
733
+			for _, client := range server.clients.AllClients() {
734
+				client.history.Resize(config.History.ClientLength)
735
+			}
736
+		}
737
+	}
738
+
820 739
 	// burst new and removed caps
821 740
 	var capBurstClients ClientSet
822 741
 	added := make(map[caps.Version]string)
@@ -1107,13 +1026,6 @@ func (target *Client) RplList(channel *Channel, rb *ResponseBuffer) {
1107 1026
 	rb.Add(nil, target.server.name, RPL_LIST, target.nick, channel.name, strconv.Itoa(memberCount), channel.topic)
1108 1027
 }
1109 1028
 
1110
-// ResumeDetails are the details that we use to resume connections.
1111
-type ResumeDetails struct {
1112
-	OldNick          string
1113
-	Timestamp        *time.Time
1114
-	SendFakeJoinsFor []string
1115
-}
1116
-
1117 1029
 var (
1118 1030
 	infoString1 = strings.Split(`      ▄▄▄   ▄▄▄·  ▄▄ •        ▐ ▄
1119 1031
 ▪     ▀▄ █·▐█ ▀█ ▐█ ▀ ▪▪     •█▌▐█▪     

+ 40
- 3
irc/socket.go View File

@@ -146,6 +146,38 @@ func (socket *Socket) Write(data []byte) (err error) {
146 146
 	return
147 147
 }
148 148
 
149
+// BlockingWrite sends the given string out of Socket. Requirements:
150
+// 1. MUST block until the message is sent
151
+// 2. MUST bypass sendq (calls to BlockingWrite cannot, on their own, cause a sendq overflow)
152
+// 3. MUST provide mutual exclusion for socket.conn.Write
153
+// 4. MUST respect the same ordering guarantees as Write (i.e., if a call to Write that sends
154
+//    message m1 happens-before a call to BlockingWrite that sends message m2,
155
+//    m1 must be sent on the wire before m2
156
+// Callers MUST be writing to the client's socket from the client's own goroutine;
157
+// other callers must use the nonblocking Write call instead. Otherwise, a client
158
+// with a slow/unreliable connection risks stalling the progress of the system as a whole.
159
+func (socket *Socket) BlockingWrite(data []byte) (err error) {
160
+	if len(data) == 0 {
161
+		return
162
+	}
163
+
164
+	// blocking acquire of the trylock
165
+	socket.writerSemaphore.Acquire()
166
+	defer socket.writerSemaphore.Release()
167
+
168
+	// first, flush any buffered data, to preserve the ordering guarantees
169
+	closed := socket.performWrite()
170
+	if closed {
171
+		return io.EOF
172
+	}
173
+
174
+	_, err = socket.conn.Write(data)
175
+	if err != nil {
176
+		socket.finalize()
177
+	}
178
+	return
179
+}
180
+
149 181
 // wakeWriter starts the goroutine that actually performs the write, without blocking
150 182
 func (socket *Socket) wakeWriter() {
151 183
 	if socket.writerSemaphore.TryAcquire() {
@@ -199,7 +231,8 @@ func (socket *Socket) send() {
199 231
 }
200 232
 
201 233
 // write the contents of the buffer, then see if we need to close
202
-func (socket *Socket) performWrite() {
234
+// returns whether we closed
235
+func (socket *Socket) performWrite() (closed bool) {
203 236
 	// retrieve the buffered data, clear the buffer
204 237
 	socket.Lock()
205 238
 	buffers := socket.buffers
@@ -214,10 +247,14 @@ func (socket *Socket) performWrite() {
214 247
 	shouldClose := (err != nil) || socket.closed || socket.sendQExceeded
215 248
 	socket.Unlock()
216 249
 
217
-	if !shouldClose {
218
-		return
250
+	if shouldClose {
251
+		socket.finalize()
219 252
 	}
253
+	return shouldClose
254
+}
220 255
 
256
+// mark closed and send final data. you must be holding the semaphore to call this:
257
+func (socket *Socket) finalize() {
221 258
 	// mark the socket closed (if someone hasn't already), then write error lines
222 259
 	socket.Lock()
223 260
 	socket.closed = true

+ 3
- 0
irc/types.go View File

@@ -6,6 +6,7 @@
6 6
 package irc
7 7
 
8 8
 import "github.com/oragono/oragono/irc/modes"
9
+import "github.com/goshuirc/irc-go/ircmsg"
9 10
 
10 11
 // ClientSet is a set of clients.
11 12
 type ClientSet map[*Client]bool
@@ -56,3 +57,5 @@ func (members MemberSet) AnyHasMode(mode modes.Mode) bool {
56 57
 
57 58
 // ChannelSet is a set of channels.
58 59
 type ChannelSet map[*Channel]bool
60
+
61
+type Tags *map[string]ircmsg.TagValue

+ 9
- 0
irc/utils/bitset.go View File

@@ -80,3 +80,12 @@ func BitsetUnion(set []uint64, other []uint64) {
80 80
 		}
81 81
 	}
82 82
 }
83
+
84
+// BitsetCopy copies the contents of `other` over `set`.
85
+// Similar caveats about race conditions as with `BitsetUnion` apply.
86
+func BitsetCopy(set []uint64, other []uint64) {
87
+	for i := 0; i < len(set); i++ {
88
+		data := atomic.LoadUint64(&other[i])
89
+		atomic.StoreUint64(&set[i], data)
90
+	}
91
+}

+ 15
- 0
irc/utils/bitset_test.go View File

@@ -62,4 +62,19 @@ func TestSets(t *testing.T) {
62 62
 			t.Error("all bits should be set except 72")
63 63
 		}
64 64
 	}
65
+
66
+	var t3 testBitset
67
+	t3s := t3[:]
68
+	BitsetSet(t3s, 72, true)
69
+	if !BitsetGet(t3s, 72) {
70
+		t.Error("bit 72 should be set")
71
+	}
72
+	// copy t1 on top of t2
73
+	BitsetCopy(t3s, t1s)
74
+	for i = 0; i < 128; i++ {
75
+		expected := (i != 72)
76
+		if BitsetGet(t1s, i) != expected {
77
+			t.Error("all bits should be set except 72")
78
+		}
79
+	}
65 80
 }

+ 30
- 0
irc/utils/crypto.go View File

@@ -0,0 +1,30 @@
1
+// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package utils
5
+
6
+import (
7
+	"crypto/rand"
8
+	"crypto/subtle"
9
+	"encoding/hex"
10
+)
11
+
12
+// generate a secret token that cannot be brute-forced via online attacks
13
+func GenerateSecretToken() string {
14
+	// 128 bits of entropy are enough to resist any online attack:
15
+	var buf [16]byte
16
+	rand.Read(buf[:])
17
+	// 32 ASCII characters, should be fine for most purposes
18
+	return hex.EncodeToString(buf[:])
19
+}
20
+
21
+// securely check if a supplied token matches a stored token
22
+func SecretTokensMatch(storedToken string, suppliedToken string) bool {
23
+	// XXX fix a potential gotcha: if the stored token is uninitialized,
24
+	// then nothing should match it, not even supplying an empty token.
25
+	if len(storedToken) == 0 {
26
+		return false
27
+	}
28
+
29
+	return subtle.ConstantTimeCompare([]byte(storedToken), []byte(suppliedToken)) == 1
30
+}

+ 48
- 0
irc/utils/crypto_test.go View File

@@ -0,0 +1,48 @@
1
+// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package utils
5
+
6
+import (
7
+	"testing"
8
+)
9
+
10
+const (
11
+	storedToken = "1e82d113a59a874cccf82063ec603221"
12
+	badToken    = "1e82d113a59a874cccf82063ec603222"
13
+	shortToken  = "1e82d113a59a874cccf82063ec60322"
14
+	longToken   = "1e82d113a59a874cccf82063ec6032211"
15
+)
16
+
17
+func TestGenerateSecretToken(t *testing.T) {
18
+	token := GenerateSecretToken()
19
+	if len(token) != 32 {
20
+		t.Errorf("bad token: %v", token)
21
+	}
22
+}
23
+
24
+func TestTokenCompare(t *testing.T) {
25
+	if !SecretTokensMatch(storedToken, storedToken) {
26
+		t.Error("matching tokens must match")
27
+	}
28
+
29
+	if SecretTokensMatch(storedToken, badToken) {
30
+		t.Error("non-matching tokens must not match")
31
+	}
32
+
33
+	if SecretTokensMatch(storedToken, shortToken) {
34
+		t.Error("non-matching tokens must not match")
35
+	}
36
+
37
+	if SecretTokensMatch(storedToken, longToken) {
38
+		t.Error("non-matching tokens must not match")
39
+	}
40
+
41
+	if SecretTokensMatch("", "") {
42
+		t.Error("the empty token should not match anything")
43
+	}
44
+
45
+	if SecretTokensMatch("", storedToken) {
46
+		t.Error("the empty token should not match anything")
47
+	}
48
+}

+ 67
- 0
irc/utils/text.go View File

@@ -0,0 +1,67 @@
1
+// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
2
+// released under the MIT license
3
+
4
+package utils
5
+
6
+import "bytes"
7
+
8
+// WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
9
+func WordWrap(text string, lineWidth int) []string {
10
+	var lines []string
11
+	var cacheLine, cacheWord bytes.Buffer
12
+
13
+	for _, char := range text {
14
+		if char == '\r' {
15
+			continue
16
+		} else if char == '\n' {
17
+			cacheLine.Write(cacheWord.Bytes())
18
+			lines = append(lines, cacheLine.String())
19
+			cacheWord.Reset()
20
+			cacheLine.Reset()
21
+		} else if (char == ' ' || char == '-') && cacheLine.Len()+cacheWord.Len()+1 < lineWidth {
22
+			// natural word boundary
23
+			cacheLine.Write(cacheWord.Bytes())
24
+			cacheLine.WriteRune(char)
25
+			cacheWord.Reset()
26
+		} else if lineWidth <= cacheLine.Len()+cacheWord.Len()+1 {
27
+			// time to wrap to next line
28
+			if cacheLine.Len() < (lineWidth / 2) {
29
+				// this word takes up more than half a line... just split in the middle of the word
30
+				cacheLine.Write(cacheWord.Bytes())
31
+				cacheLine.WriteRune(char)
32
+				cacheWord.Reset()
33
+			} else {
34
+				cacheWord.WriteRune(char)
35
+			}
36
+			lines = append(lines, cacheLine.String())
37
+			cacheLine.Reset()
38
+		} else {
39
+			// normal character
40
+			cacheWord.WriteRune(char)
41
+		}
42
+	}
43
+	if 0 < cacheWord.Len() {
44
+		cacheLine.Write(cacheWord.Bytes())
45
+	}
46
+	if 0 < cacheLine.Len() {
47
+		lines = append(lines, cacheLine.String())
48
+	}
49
+
50
+	return lines
51
+}
52
+
53
+// SplitMessage represents a message that's been split for sending.
54
+type SplitMessage struct {
55
+	Original string
56
+	Wrapped  []string // if this is nil, Original didn't need wrapping and can be sent to anyone
57
+}
58
+
59
+func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) {
60
+	result.Original = original
61
+
62
+	if !origIs512 {
63
+		result.Wrapped = WordWrap(original, 400)
64
+	}
65
+
66
+	return
67
+}

+ 60
- 0
irc/utils/text_test.go View File

@@ -0,0 +1,60 @@
1
+// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package utils
5
+
6
+import (
7
+	"reflect"
8
+	"strings"
9
+	"testing"
10
+)
11
+
12
+const (
13
+	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."
14
+
15
+	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.`
16
+)
17
+
18
+func assertWrapCorrect(text string, lineWidth int, allowSplitWords bool, t *testing.T) {
19
+	lines := WordWrap(text, lineWidth)
20
+
21
+	reconstructed := strings.Join(lines, "")
22
+	if text != reconstructed {
23
+		t.Errorf("text %v does not match original %v", text, reconstructed)
24
+	}
25
+
26
+	for _, line := range lines {
27
+		if len(line) > lineWidth {
28
+			t.Errorf("line too long: %d, %v", len(line), line)
29
+		}
30
+	}
31
+
32
+	if !allowSplitWords {
33
+		origWords := strings.Fields(text)
34
+		var newWords []string
35
+		for _, line := range lines {
36
+			newWords = append(newWords, strings.Fields(line)...)
37
+		}
38
+
39
+		if !reflect.DeepEqual(origWords, newWords) {
40
+			t.Errorf("words %v do not match wrapped words %v", origWords, newWords)
41
+		}
42
+	}
43
+
44
+}
45
+
46
+func TestWordWrap(t *testing.T) {
47
+	assertWrapCorrect("jackdaws love my big sphinx of quartz", 12, false, t)
48
+	// long word that will necessarily be split:
49
+	assertWrapCorrect("jackdawslovemybigsphinxofquartz", 12, true, t)
50
+
51
+	assertWrapCorrect(threeMusketeers, 40, true, t)
52
+	assertWrapCorrect(monteCristo, 20, false, t)
53
+}
54
+
55
+func BenchmarkWordWrap(b *testing.B) {
56
+	for i := 0; i < b.N; i++ {
57
+		WordWrap(threeMusketeers, 40)
58
+		WordWrap(monteCristo, 60)
59
+	}
60
+}

+ 15
- 0
oragono.yaml View File

@@ -92,6 +92,10 @@ server:
92 92
                 # - "127.0.0.1/8"
93 93
                 # - "0::1"
94 94
 
95
+    # allow use of the RESUME extension over plaintext connections:
96
+    # do not enable this unless the ircd is only accessible over internal networks
97
+    allow-plaintext-resume: false
98
+
95 99
     # maximum length of clients' sendQ in bytes
96 100
     # this should be big enough to hold /LIST and HELP replies
97 101
     max-sendq: 16k
@@ -439,3 +443,14 @@ fakelag:
439 443
     # client status resets to the default state if they go this long without
440 444
     # sending any commands:
441 445
     cooldown: 2s
446
+
447
+# message history tracking, for the RESUME extension and possibly other uses in future
448
+history:
449
+    # should we store messages for later playback?
450
+    enabled: true
451
+
452
+    # how many channel-specific events (messages, joins, parts) should be tracked per channel?
453
+    channel-length: 256
454
+
455
+    # how many direct messages and notices should be tracked per user?
456
+    client-length: 64

Loading…
Cancel
Save