Browse Source

fix #858 and #383

tags/v2.1.0-rc1
Shivaram Lingamneni 4 years ago
parent
commit
67f35e5c8a
16 changed files with 818 additions and 107 deletions
  1. 17
    1
      conventional.yaml
  2. 24
    0
      irc/accounts.go
  3. 21
    27
      irc/channel.go
  4. 3
    3
      irc/chanserv.go
  5. 1
    1
      irc/client.go
  6. 11
    0
      irc/config.go
  7. 12
    45
      irc/handlers.go
  8. 26
    0
      irc/history/history.go
  9. 242
    0
      irc/histserv.go
  10. 2
    1
      irc/mysql/config.go
  11. 363
    26
      irc/mysql/history.go
  12. 1
    1
      irc/nickname.go
  13. 1
    1
      irc/roleplay.go
  14. 70
    0
      irc/server.go
  15. 7
    0
      irc/services.go
  16. 17
    1
      oragono.yaml

+ 17
- 1
conventional.yaml View File

@@ -259,6 +259,10 @@ server:
259 259
     secure-nets:
260 260
         # - "10.0.0.0/8"
261 261
 
262
+    # oragono will write files to disk under certain circumstances, e.g.,
263
+    # CPU profiling or data export. by default, these files will be written
264
+    # to the working directory. set this to customize:
265
+    # output-path: "/home/oragono/out"
262 266
 
263 267
 # account options
264 268
 accounts:
@@ -556,6 +560,7 @@ oper-classes:
556 560
             - "samode"
557 561
             - "vhosts"
558 562
             - "chanreg"
563
+            - "history"
559 564
 
560 565
 # ircd operators
561 566
 opers:
@@ -751,7 +756,8 @@ roleplay:
751 756
     # add the real nickname, in parentheses, to the end of every roleplay message?
752 757
     add-suffix: true
753 758
 
754
-# message history tracking, for the RESUME extension and possibly other uses in future
759
+# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
760
+# various autoreplay features, and the resume extension
755 761
 history:
756 762
     # should we store messages for later playback?
757 763
     # by default, messages are stored in RAM only; they do not persist
@@ -820,3 +826,13 @@ history:
820 826
         # if you enable this, strict nickname reservation is strongly recommended
821 827
         # as well.
822 828
         direct-messages: "opt-out"
829
+
830
+    # options to control how messages are stored and deleted:
831
+    retention:
832
+        # allow users to delete their own messages from history?
833
+        allow-individual-delete: false
834
+
835
+        # if persistent history is enabled, create additional index tables,
836
+        # allowing deletion of JSON export of an account's messages. this
837
+        # may be needed for compliance with data privacy regulations.
838
+        enable-account-indexing: false

+ 24
- 0
irc/accounts.go View File

@@ -1103,6 +1103,30 @@ func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount,
1103 1103
 	return
1104 1104
 }
1105 1105
 
1106
+// look up the unfolded version of an account name, possibly after deletion
1107
+func (am *AccountManager) AccountToAccountName(account string) (result string) {
1108
+	casefoldedAccount, err := CasefoldName(account)
1109
+	if err != nil {
1110
+		return
1111
+	}
1112
+
1113
+	unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
1114
+	accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
1115
+
1116
+	am.server.store.View(func(tx *buntdb.Tx) error {
1117
+		if name, err := tx.Get(accountNameKey); err == nil {
1118
+			result = name
1119
+			return nil
1120
+		}
1121
+		if name, err := tx.Get(unregisteredKey); err == nil {
1122
+			result = name
1123
+		}
1124
+		return nil
1125
+	})
1126
+
1127
+	return
1128
+}
1129
+
1106 1130
 func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName string) (result ClientAccount, err error) {
1107 1131
 	result.Name = raw.Name
1108 1132
 	result.NameCasefolded = cfName

+ 21
- 27
irc/channel.go View File

@@ -20,10 +20,6 @@ import (
20 20
 	"github.com/oragono/oragono/irc/utils"
21 21
 )
22 22
 
23
-const (
24
-	histServMask = "HistServ!HistServ@localhost"
25
-)
26
-
27 23
 type ChannelSettings struct {
28 24
 	History HistoryStatus
29 25
 }
@@ -641,14 +637,14 @@ func channelHistoryStatus(config *Config, registered bool, storedStatus HistoryS
641 637
 	}
642 638
 }
643 639
 
644
-func (channel *Channel) AddHistoryItem(item history.Item) (err error) {
640
+func (channel *Channel) AddHistoryItem(item history.Item, account string) (err error) {
645 641
 	if !item.IsStorable() {
646 642
 		return
647 643
 	}
648 644
 
649 645
 	status, target := channel.historyStatus(channel.server.Config())
650 646
 	if status == HistoryPersistent {
651
-		err = channel.server.historyDB.AddChannelItem(target, item)
647
+		err = channel.server.historyDB.AddChannelItem(target, item, account)
652 648
 	} else if status == HistoryEphemeral {
653 649
 		channel.history.Add(item)
654 650
 	}
@@ -746,7 +742,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
746 742
 			Message:     message,
747 743
 		}
748 744
 		histItem.Params[0] = details.realname
749
-		channel.AddHistoryItem(histItem)
745
+		channel.AddHistoryItem(histItem, details.account)
750 746
 	}
751 747
 
752 748
 	client.addChannel(channel, rb == nil)
@@ -902,7 +898,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
902 898
 		Nick:        details.nickMask,
903 899
 		AccountName: details.accountName,
904 900
 		Message:     splitMessage,
905
-	})
901
+	}, details.account)
906 902
 
907 903
 	client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", details.nick, chname))
908 904
 }
@@ -1165,7 +1161,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
1165 1161
 		Nick:        details.nickMask,
1166 1162
 		AccountName: details.accountName,
1167 1163
 		Message:     message,
1168
-	})
1164
+	}, details.account)
1169 1165
 
1170 1166
 	channel.MarkDirty(IncludeTopic)
1171 1167
 }
@@ -1222,8 +1218,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
1222 1218
 		return
1223 1219
 	}
1224 1220
 
1225
-	nickmask := client.NickMaskString()
1226
-	account := client.AccountName()
1221
+	details := client.Details()
1227 1222
 	chname := channel.Name()
1228 1223
 
1229 1224
 	// STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel
@@ -1238,9 +1233,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
1238 1233
 			tagsToUse = clientOnlyTags
1239 1234
 		}
1240 1235
 		if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
1241
-			rb.AddFromClient(message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
1236
+			rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
1242 1237
 		} else {
1243
-			rb.AddSplitMessageFromClient(nickmask, account, tagsToUse, command, chname, message)
1238
+			rb.AddSplitMessageFromClient(details.nickMask, details.accountName, tagsToUse, command, chname, message)
1244 1239
 		}
1245 1240
 	}
1246 1241
 	// send echo-message to other connected sessions
@@ -1253,9 +1248,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
1253 1248
 			tagsToUse = clientOnlyTags
1254 1249
 		}
1255 1250
 		if histType == history.Tagmsg && session.capabilities.Has(caps.MessageTags) {
1256
-			session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
1251
+			session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
1257 1252
 		} else if histType != history.Tagmsg {
1258
-			session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message)
1253
+			session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message)
1259 1254
 		}
1260 1255
 	}
1261 1256
 
@@ -1282,9 +1277,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
1282 1277
 			}
1283 1278
 
1284 1279
 			if histType == history.Tagmsg {
1285
-				session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
1280
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
1286 1281
 			} else {
1287
-				session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message)
1282
+				session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message)
1288 1283
 			}
1289 1284
 		}
1290 1285
 	}
@@ -1294,10 +1289,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
1294 1289
 		channel.AddHistoryItem(history.Item{
1295 1290
 			Type:        histType,
1296 1291
 			Message:     message,
1297
-			Nick:        nickmask,
1298
-			AccountName: account,
1292
+			Nick:        details.nickMask,
1293
+			AccountName: details.accountName,
1299 1294
 			Tags:        clientOnlyTags,
1300
-		})
1295
+		}, details.account)
1301 1296
 	}
1302 1297
 }
1303 1298
 
@@ -1391,28 +1386,27 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
1391 1386
 	}
1392 1387
 
1393 1388
 	message := utils.MakeMessage(comment)
1394
-	clientMask := client.NickMaskString()
1395
-	clientAccount := client.AccountName()
1389
+	details := client.Details()
1396 1390
 
1397 1391
 	targetNick := target.Nick()
1398 1392
 	chname := channel.Name()
1399 1393
 	for _, member := range channel.Members() {
1400 1394
 		for _, session := range member.Sessions() {
1401 1395
 			if session != rb.session {
1402
-				session.sendFromClientInternal(false, message.Time, message.Msgid, clientMask, clientAccount, nil, "KICK", chname, targetNick, comment)
1396
+				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
1403 1397
 			}
1404 1398
 		}
1405 1399
 	}
1406
-	rb.Add(nil, clientMask, "KICK", chname, targetNick, comment)
1400
+	rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
1407 1401
 
1408 1402
 	histItem := history.Item{
1409 1403
 		Type:        history.Kick,
1410
-		Nick:        clientMask,
1411
-		AccountName: target.AccountName(),
1404
+		Nick:        details.nickMask,
1405
+		AccountName: details.accountName,
1412 1406
 		Message:     message,
1413 1407
 	}
1414 1408
 	histItem.Params[0] = targetNick
1415
-	channel.AddHistoryItem(histItem)
1409
+	channel.AddHistoryItem(histItem, details.account)
1416 1410
 
1417 1411
 	channel.Quit(target)
1418 1412
 }

+ 3
- 3
irc/chanserv.go View File

@@ -255,7 +255,7 @@ func csAmodeHandler(server *Server, client *Client, command string, params []str
255 255
 				if member.Account() == change.Arg {
256 256
 					applied, change := channel.applyModeToMember(client, change, rb)
257 257
 					if applied {
258
-						announceCmodeChanges(channel, modes.ModeChanges{change}, chanservMask, "*", rb)
258
+						announceCmodeChanges(channel, modes.ModeChanges{change}, chanservMask, "*", "", rb)
259 259
 					}
260 260
 				}
261 261
 			}
@@ -302,7 +302,7 @@ func csOpHandler(server *Server, client *Client, command string, params []string
302 302
 		},
303 303
 		rb)
304 304
 	if applied {
305
-		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", rb)
305
+		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", "", rb)
306 306
 	}
307 307
 
308 308
 	csNotice(rb, fmt.Sprintf(client.t("Successfully op'd in channel %s"), channelName))
@@ -354,7 +354,7 @@ func csRegisterHandler(server *Server, client *Client, command string, params []
354 354
 		},
355 355
 		rb)
356 356
 	if applied {
357
-		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", rb)
357
+		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", "", rb)
358 358
 	}
359 359
 }
360 360
 

+ 1
- 1
irc/client.go View File

@@ -1277,7 +1277,7 @@ func (client *Client) destroy(session *Session) {
1277 1277
 	// use a defer here to avoid writing to mysql while holding the destroy semaphore:
1278 1278
 	defer func() {
1279 1279
 		for _, channel := range channels {
1280
-			channel.AddHistoryItem(quitItem)
1280
+			channel.AddHistoryItem(quitItem, details.account)
1281 1281
 		}
1282 1282
 	}()
1283 1283
 

+ 11
- 0
irc/config.go View File

@@ -13,6 +13,7 @@ import (
13 13
 	"log"
14 14
 	"net"
15 15
 	"os"
16
+	"path/filepath"
16 17
 	"regexp"
17 18
 	"sort"
18 19
 	"strconv"
@@ -511,6 +512,7 @@ type Config struct {
511 512
 		supportedCaps *caps.Set
512 513
 		capValues     caps.Values
513 514
 		Casemapping   Casemapping
515
+		OutputPath    string `yaml:"output-path"`
514 516
 	}
515 517
 
516 518
 	Roleplay struct {
@@ -590,6 +592,10 @@ type Config struct {
590 592
 			RegisteredChannels   PersistentStatus `yaml:"registered-channels"`
591 593
 			DirectMessages       PersistentStatus `yaml:"direct-messages"`
592 594
 		}
595
+		Retention struct {
596
+			AllowIndividualDelete bool `yaml:"allow-individual-delete"`
597
+			EnableAccountIndexing bool `yaml:"enable-account-indexing"`
598
+		}
593 599
 	}
594 600
 
595 601
 	Filename string
@@ -1111,6 +1117,7 @@ func LoadConfig(filename string) (config *Config, err error) {
1111 1117
 	config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
1112 1118
 
1113 1119
 	config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
1120
+	config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
1114 1121
 
1115 1122
 	config.Server.Cloaks.Initialize()
1116 1123
 	if config.Server.Cloaks.Enabled {
@@ -1133,6 +1140,10 @@ func LoadConfig(filename string) (config *Config, err error) {
1133 1140
 	return config, nil
1134 1141
 }
1135 1142
 
1143
+func (config *Config) getOutputPath(filename string) string {
1144
+	return filepath.Join(config.Server.OutputPath, filename)
1145
+}
1146
+
1136 1147
 // setISupport sets up our RPL_ISUPPORT reply.
1137 1148
 func (config *Config) generateISupport() (err error) {
1138 1149
 	maxTargetsString := strconv.Itoa(maxTargets)

+ 12
- 45
irc/handlers.go View File

@@ -677,7 +677,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
677 677
 		rb.Notice(fmt.Sprintf("num goroutines: %d", count))
678 678
 
679 679
 	case "PROFILEHEAP":
680
-		profFile := "oragono.mprof"
680
+		profFile := server.Config().getOutputPath("oragono.mprof")
681 681
 		file, err := os.Create(profFile)
682 682
 		if err != nil {
683 683
 			rb.Notice(fmt.Sprintf("error: %s", err))
@@ -688,7 +688,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
688 688
 		rb.Notice(fmt.Sprintf("written to %s", profFile))
689 689
 
690 690
 	case "STARTCPUPROFILE":
691
-		profFile := "oragono.prof"
691
+		profFile := server.Config().getOutputPath("oragono.prof")
692 692
 		file, err := os.Create(profFile)
693 693
 		if err != nil {
694 694
 			rb.Notice(fmt.Sprintf("error: %s", err))
@@ -935,50 +935,17 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
935 935
 		return false
936 936
 	}
937 937
 
938
-	target := msg.Params[0]
939
-	if strings.ToLower(target) == "me" {
940
-		target = "*"
941
-	}
942
-	channel, sequence, err := server.GetHistorySequence(nil, client, target)
938
+	items, channel, err := easySelectHistory(server, client, msg.Params)
943 939
 
944
-	if sequence == nil || err != nil {
945
-		// whatever
946
-		rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
940
+	if err == errNoSuchChannel {
941
+		rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel"))
942
+		return false
943
+	} else if err != nil {
944
+		rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Could not retrieve history"))
947 945
 		return false
948 946
 	}
949 947
 
950
-	var duration time.Duration
951
-	maxChathistoryLimit := config.History.ChathistoryMax
952
-	limit := 100
953
-	if maxChathistoryLimit < limit {
954
-		limit = maxChathistoryLimit
955
-	}
956
-	if len(msg.Params) > 1 {
957
-		providedLimit, err := strconv.Atoi(msg.Params[1])
958
-		if err == nil && providedLimit != 0 {
959
-			limit = providedLimit
960
-			if maxChathistoryLimit < limit {
961
-				limit = maxChathistoryLimit
962
-			}
963
-		} else if err != nil {
964
-			duration, err = time.ParseDuration(msg.Params[1])
965
-			if err == nil {
966
-				limit = maxChathistoryLimit
967
-			}
968
-		}
969
-	}
970
-
971
-	var items []history.Item
972
-	if duration == 0 {
973
-		items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
974
-	} else {
975
-		now := time.Now().UTC()
976
-		start := history.Selector{Time: now}
977
-		end := history.Selector{Time: now.Add(-duration)}
978
-		items, _, err = sequence.Between(start, end, limit)
979
-	}
980
-
981
-	if err == nil && len(items) != 0 {
948
+	if len(items) != 0 {
982 949
 		if channel != nil {
983 950
 			channel.replayHistoryItems(rb, items, false)
984 951
 		} else {
@@ -1530,12 +1497,12 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
1530 1497
 	// process mode changes, include list operations (an empty set of changes does a list)
1531 1498
 	applied := channel.ApplyChannelModeChanges(client, msg.Command == "SAMODE", changes, rb)
1532 1499
 	details := client.Details()
1533
-	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, rb)
1500
+	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, rb)
1534 1501
 
1535 1502
 	return false
1536 1503
 }
1537 1504
 
1538
-func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName string, rb *ResponseBuffer) {
1505
+func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName, account string, rb *ResponseBuffer) {
1539 1506
 	// send out changes
1540 1507
 	if len(applied) > 0 {
1541 1508
 		message := utils.MakeMessage("")
@@ -1557,7 +1524,7 @@ func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, a
1557 1524
 			Nick:        source,
1558 1525
 			AccountName: accountName,
1559 1526
 			Message:     message,
1560
-		})
1527
+		}, account)
1561 1528
 	}
1562 1529
 }
1563 1530
 

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

@@ -284,6 +284,32 @@ func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int
284 284
 	return
285 285
 }
286 286
 
287
+// Delete deletes messages matching some predicate.
288
+func (list *Buffer) Delete(predicate Predicate) (count int) {
289
+	list.Lock()
290
+	defer list.Unlock()
291
+
292
+	if list.start == -1 || len(list.buffer) == 0 {
293
+		return
294
+	}
295
+
296
+	pos := list.start
297
+	stop := list.prev(list.end)
298
+
299
+	for {
300
+		if predicate(&list.buffer[pos]) {
301
+			list.buffer[pos] = Item{}
302
+			count++
303
+		}
304
+		if pos == stop {
305
+			break
306
+		}
307
+		pos = list.next(pos)
308
+	}
309
+
310
+	return
311
+}
312
+
287 313
 // latest returns the items most recently added, up to `limit`. If `limit` is 0,
288 314
 // it returns all items.
289 315
 func (list *Buffer) latest(limit int) (results []Item) {

+ 242
- 0
irc/histserv.go View File

@@ -0,0 +1,242 @@
1
+// Copyright (c) 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package irc
5
+
6
+import (
7
+	"bufio"
8
+	"fmt"
9
+	"os"
10
+	"runtime/debug"
11
+	"strconv"
12
+	"strings"
13
+	"time"
14
+
15
+	"github.com/oragono/oragono/irc/history"
16
+)
17
+
18
+const (
19
+	histservHelp = `HistServ provides commands related to history.`
20
+	histServMask = "HistServ!HistServ@localhost"
21
+)
22
+
23
+func histservEnabled(config *Config) bool {
24
+	return config.History.Enabled
25
+}
26
+
27
+func historyComplianceEnabled(config *Config) bool {
28
+	return config.History.Enabled && config.History.Persistent.Enabled && config.History.Retention.EnableAccountIndexing
29
+}
30
+
31
+var (
32
+	histservCommands = map[string]*serviceCommand{
33
+		"forget": {
34
+			handler: histservForgetHandler,
35
+			help: `Syntax: $bFORGET <account>$b
36
+
37
+FORGET deletes all history messages sent by an account.`,
38
+			helpShort: `$bFORGET$b deletes all history messages sent by an account.`,
39
+			capabs:    []string{"history"},
40
+			enabled:   histservEnabled,
41
+			minParams: 1,
42
+			maxParams: 1,
43
+		},
44
+		"delete": {
45
+			handler: histservDeleteHandler,
46
+			help: `Syntax: $bDELETE [target] <msgid>$b
47
+
48
+DELETE deletes an individual message by its msgid. The target is a channel
49
+name or nickname; depending on the history implementation, this may or may not
50
+be necessary to locate the message.`,
51
+			helpShort: `$bDELETE$b deletes an individual message by its msgid.`,
52
+			enabled:   histservEnabled,
53
+			minParams: 1,
54
+			maxParams: 2,
55
+		},
56
+		"export": {
57
+			handler: histservExportHandler,
58
+			help: `Syntax: $bEXPORT <account>$b
59
+
60
+EXPORT exports all messages sent by an account as JSON. This can be used
61
+for regulatory compliance, e.g., article 15 of the GDPR.`,
62
+			helpShort: `$bEXPORT$b exports all messages sent by an account as JSON.`,
63
+			enabled:   historyComplianceEnabled,
64
+			capabs:    []string{"history"},
65
+			minParams: 1,
66
+			maxParams: 1,
67
+		},
68
+		"play": {
69
+			handler: histservPlayHandler,
70
+			help: `Syntax: $bPLAY <target> [limit]$b
71
+
72
+PLAY plays back history messages, rendering them into direct messages from
73
+HistServ. 'target' is a channel name (or 'me' for direct messages), and 'limit'
74
+is a message count or a time duration. Note that message playback may be
75
+incomplete or degraded, relative to direct playback from /HISTORY or
76
+CHATHISTORY.`,
77
+			helpShort: `$bPLAY$b plays back history messages.`,
78
+			enabled:   histservEnabled,
79
+			minParams: 1,
80
+			maxParams: 2,
81
+		},
82
+	}
83
+)
84
+
85
+// histNotice sends the client a notice from HistServ
86
+func histNotice(rb *ResponseBuffer, text string) {
87
+	rb.Add(nil, histServMask, "NOTICE", rb.target.Nick(), text)
88
+}
89
+
90
+func histservForgetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
91
+	accountName := server.accounts.AccountToAccountName(params[0])
92
+	if accountName == "" {
93
+		histNotice(rb, client.t("Could not look up account name, proceeding anyway"))
94
+		accountName = params[0]
95
+	}
96
+
97
+	server.ForgetHistory(accountName)
98
+
99
+	histNotice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
100
+}
101
+
102
+func histservDeleteHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
103
+	var target, msgid string
104
+	if len(params) == 1 {
105
+		msgid = params[0]
106
+	} else {
107
+		target, msgid = params[0], params[1]
108
+	}
109
+
110
+	accountName := "*"
111
+	hasPrivs := client.HasRoleCapabs("history")
112
+	if !hasPrivs {
113
+		accountName = client.AccountName()
114
+		if !(server.Config().History.Retention.AllowIndividualDelete && accountName != "*") {
115
+			hsNotice(rb, client.t("Insufficient privileges"))
116
+			return
117
+		}
118
+	}
119
+
120
+	err := server.DeleteMessage(target, msgid, accountName)
121
+	if err == nil {
122
+		hsNotice(rb, client.t("Successfully deleted message"))
123
+	} else {
124
+		if hasPrivs {
125
+			hsNotice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
126
+		} else {
127
+			hsNotice(rb, client.t("Could not delete message"))
128
+		}
129
+	}
130
+}
131
+
132
+func histservExportHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
133
+	cfAccount, err := CasefoldName(params[0])
134
+	if err != nil {
135
+		histNotice(rb, client.t("Invalid account name"))
136
+		return
137
+	}
138
+
139
+	config := server.Config()
140
+	filename := fmt.Sprintf("%s@%s.json", cfAccount, time.Now().UTC().Format(IRCv3TimestampFormat))
141
+	pathname := config.getOutputPath(filename)
142
+	outfile, err := os.Create(pathname)
143
+	if err != nil {
144
+		hsNotice(rb, fmt.Sprintf(client.t("Error opening export file: %v"), err))
145
+	} else {
146
+		hsNotice(rb, fmt.Sprintf(client.t("Started exporting account data to file %s"), pathname))
147
+	}
148
+
149
+	go histservExportAndNotify(server, cfAccount, outfile, client.Nick())
150
+}
151
+
152
+func histservExportAndNotify(server *Server, cfAccount string, outfile *os.File, alertNick string) {
153
+	defer func() {
154
+		if r := recover(); r != nil {
155
+			server.logger.Error("history",
156
+				fmt.Sprintf("Panic in history export routine: %v\n%s", r, debug.Stack()))
157
+		}
158
+	}()
159
+
160
+	defer outfile.Close()
161
+	writer := bufio.NewWriter(outfile)
162
+	defer writer.Flush()
163
+
164
+	server.historyDB.Export(cfAccount, writer)
165
+
166
+	client := server.clients.Get(alertNick)
167
+	if client != nil && client.HasRoleCapabs("history") {
168
+		client.Send(nil, histServMask, "NOTICE", client.Nick(), fmt.Sprintf(client.t("Data export for %[1]s completed and written to %[2]s"), cfAccount, outfile.Name()))
169
+	}
170
+}
171
+
172
+func histservPlayHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
173
+	items, _, err := easySelectHistory(server, client, params)
174
+	if err != nil {
175
+		hsNotice(rb, client.t("Could not retrieve history"))
176
+		return
177
+	}
178
+
179
+	playMessage := func(timestamp time.Time, nick, message string) {
180
+		hsNotice(rb, fmt.Sprintf("%s <%s> %s", timestamp.Format("15:04:05"), stripMaskFromNick(nick), message))
181
+	}
182
+
183
+	for _, item := range items {
184
+		// TODO: support a few more of these, maybe JOIN/PART/QUIT
185
+		if item.Type != history.Privmsg && item.Type != history.Notice {
186
+			continue
187
+		}
188
+		if len(item.Message.Split) == 0 {
189
+			playMessage(item.Message.Time, item.Nick, item.Message.Message)
190
+		} else {
191
+			for _, pair := range item.Message.Split {
192
+				playMessage(item.Message.Time, item.Nick, pair.Message)
193
+			}
194
+		}
195
+	}
196
+
197
+	hsNotice(rb, client.t("End of history playback"))
198
+}
199
+
200
+// handles parameter parsing and history queries for /HISTORY and /HISTSERV PLAY
201
+func easySelectHistory(server *Server, client *Client, params []string) (items []history.Item, channel *Channel, err error) {
202
+	target := params[0]
203
+	if strings.ToLower(target) == "me" {
204
+		target = "*"
205
+	}
206
+	channel, sequence, err := server.GetHistorySequence(nil, client, target)
207
+
208
+	if sequence == nil || err != nil {
209
+		return nil, nil, errNoSuchChannel
210
+	}
211
+
212
+	var duration time.Duration
213
+	maxChathistoryLimit := server.Config().History.ChathistoryMax
214
+	limit := 100
215
+	if maxChathistoryLimit < limit {
216
+		limit = maxChathistoryLimit
217
+	}
218
+	if len(params) > 1 {
219
+		providedLimit, err := strconv.Atoi(params[1])
220
+		if err == nil && providedLimit != 0 {
221
+			limit = providedLimit
222
+			if maxChathistoryLimit < limit {
223
+				limit = maxChathistoryLimit
224
+			}
225
+		} else if err != nil {
226
+			duration, err = time.ParseDuration(params[1])
227
+			if err == nil {
228
+				limit = maxChathistoryLimit
229
+			}
230
+		}
231
+	}
232
+
233
+	if duration == 0 {
234
+		items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
235
+	} else {
236
+		now := time.Now().UTC()
237
+		start := history.Selector{Time: now}
238
+		end := history.Selector{Time: now.Add(-duration)}
239
+		items, _, err = sequence.Between(start, end, limit)
240
+	}
241
+	return
242
+}

+ 2
- 1
irc/mysql/config.go View File

@@ -18,5 +18,6 @@ type Config struct {
18 18
 	Timeout         time.Duration
19 19
 
20 20
 	// XXX these are copied from elsewhere in the config:
21
-	ExpireTime time.Duration
21
+	ExpireTime           time.Duration
22
+	TrackAccountMessages bool
22 23
 }

+ 363
- 26
irc/mysql/history.go View File

@@ -7,7 +7,10 @@ import (
7 7
 	"bytes"
8 8
 	"context"
9 9
 	"database/sql"
10
+	"encoding/json"
11
+	"errors"
10 12
 	"fmt"
13
+	"io"
11 14
 	"runtime/debug"
12 15
 	"sync"
13 16
 	"sync/atomic"
@@ -19,6 +22,10 @@ import (
19 22
 	"github.com/oragono/oragono/irc/utils"
20 23
 )
21 24
 
25
+var (
26
+	ErrDisallowed = errors.New("disallowed")
27
+)
28
+
22 29
 const (
23 30
 	// maximum length in bytes of any message target (nickname or channel name) in its
24 31
 	// canonicalized (i.e., casefolded) state:
@@ -27,30 +34,46 @@ const (
27 34
 	// latest schema of the db
28 35
 	latestDbSchema   = "2"
29 36
 	keySchemaVersion = "db.version"
30
-	cleanupRowLimit  = 50
31
-	cleanupPauseTime = 10 * time.Minute
37
+	// minor version indicates rollback-safe upgrades, i.e.,
38
+	// you can downgrade oragono and everything will work
39
+	latestDbMinorVersion  = "1"
40
+	keySchemaMinorVersion = "db.minorversion"
41
+	cleanupRowLimit       = 50
42
+	cleanupPauseTime      = 10 * time.Minute
32 43
 )
33 44
 
45
+type e struct{}
46
+
34 47
 type MySQL struct {
35
-	timeout int64
36
-	db      *sql.DB
37
-	logger  *logger.Manager
48
+	timeout              int64
49
+	trackAccountMessages uint32
50
+	db                   *sql.DB
51
+	logger               *logger.Manager
38 52
 
39
-	insertHistory      *sql.Stmt
40
-	insertSequence     *sql.Stmt
41
-	insertConversation *sql.Stmt
53
+	insertHistory        *sql.Stmt
54
+	insertSequence       *sql.Stmt
55
+	insertConversation   *sql.Stmt
56
+	insertAccountMessage *sql.Stmt
42 57
 
43 58
 	stateMutex sync.Mutex
44 59
 	config     Config
60
+
61
+	wakeForgetter chan e
45 62
 }
46 63
 
47 64
 func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
48 65
 	mysql.logger = logger
66
+	mysql.wakeForgetter = make(chan e, 1)
49 67
 	mysql.SetConfig(config)
50 68
 }
51 69
 
52 70
 func (mysql *MySQL) SetConfig(config Config) {
53 71
 	atomic.StoreInt64(&mysql.timeout, int64(config.Timeout))
72
+	var trackAccountMessages uint32
73
+	if config.TrackAccountMessages {
74
+		trackAccountMessages = 1
75
+	}
76
+	atomic.StoreUint32(&mysql.trackAccountMessages, trackAccountMessages)
54 77
 	mysql.stateMutex.Lock()
55 78
 	mysql.config = config
56 79
 	mysql.stateMutex.Unlock()
@@ -85,6 +108,7 @@ func (m *MySQL) Open() (err error) {
85 108
 	}
86 109
 
87 110
 	go m.cleanupLoop()
111
+	go m.forgetLoop()
88 112
 
89 113
 	return nil
90 114
 }
@@ -109,14 +133,35 @@ func (mysql *MySQL) fixSchemas() (err error) {
109 133
 		if err != nil {
110 134
 			return
111 135
 		}
136
+		_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
137
+		if err != nil {
138
+			return
139
+		}
140
+		return
112 141
 	} else if err == nil && schema != latestDbSchema {
113 142
 		// TODO figure out what to do about schema changes
114 143
 		return &utils.IncompatibleSchemaError{CurrentVersion: schema, RequiredVersion: latestDbSchema}
115
-	} else {
144
+	} else if err != nil {
116 145
 		return err
117 146
 	}
118 147
 
119
-	return nil
148
+	var minorVersion string
149
+	err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
150
+	if err == sql.ErrNoRows {
151
+		// XXX for now, the only minor version upgrade is the account tracking tables
152
+		err = mysql.createComplianceTables()
153
+		if err != nil {
154
+			return
155
+		}
156
+		_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
157
+		if err != nil {
158
+			return
159
+		}
160
+	} else if err == nil && minorVersion != latestDbMinorVersion {
161
+		// TODO: if minorVersion < latestDbMinorVersion, upgrade,
162
+		// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
163
+	}
164
+	return
120 165
 }
121 166
 
122 167
 func (mysql *MySQL) createTables() (err error) {
@@ -155,6 +200,32 @@ func (mysql *MySQL) createTables() (err error) {
155 200
 		return err
156 201
 	}
157 202
 
203
+	err = mysql.createComplianceTables()
204
+	if err != nil {
205
+		return err
206
+	}
207
+
208
+	return nil
209
+}
210
+
211
+func (mysql *MySQL) createComplianceTables() (err error) {
212
+	_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE account_messages (
213
+		history_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
214
+		account VARBINARY(%[1]d) NOT NULL,
215
+		KEY (account, history_id)
216
+	) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
217
+	if err != nil {
218
+		return err
219
+	}
220
+
221
+	_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE forget (
222
+		id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
223
+		account VARBINARY(%[1]d) NOT NULL
224
+	) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
225
+	if err != nil {
226
+		return err
227
+	}
228
+
158 229
 	return nil
159 230
 }
160 231
 
@@ -191,7 +262,10 @@ func (mysql *MySQL) cleanupLoop() {
191 262
 }
192 263
 
193 264
 func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
194
-	ids, maxNanotime, err := mysql.selectCleanupIDs(age)
265
+	ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
266
+	defer cancel()
267
+
268
+	ids, maxNanotime, err := mysql.selectCleanupIDs(ctx, age)
195 269
 	if len(ids) == 0 {
196 270
 		mysql.logger.Debug("mysql", "found no rows to clean up")
197 271
 		return
@@ -199,6 +273,10 @@ func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
199 273
 
200 274
 	mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime)))
201 275
 
276
+	return len(ids), mysql.deleteHistoryIDs(ctx, ids)
277
+}
278
+
279
+func (mysql *MySQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err error) {
202 280
 	// can't use ? binding for a variable number of arguments, build the IN clause manually
203 281
 	var inBuf bytes.Buffer
204 282
 	inBuf.WriteByte('(')
@@ -210,25 +288,30 @@ func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
210 288
 	}
211 289
 	inBuf.WriteRune(')')
212 290
 
213
-	_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
291
+	_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
214 292
 	if err != nil {
215 293
 		return
216 294
 	}
217
-	_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
295
+	_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
218 296
 	if err != nil {
219 297
 		return
220 298
 	}
221
-	_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
299
+	if mysql.isTrackingAccountMessages() {
300
+		_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM account_messages WHERE history_id in %s;`, inBuf.Bytes()))
301
+		if err != nil {
302
+			return
303
+		}
304
+	}
305
+	_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
222 306
 	if err != nil {
223 307
 		return
224 308
 	}
225 309
 
226
-	count = len(ids)
227 310
 	return
228 311
 }
229 312
 
230
-func (mysql *MySQL) selectCleanupIDs(age time.Duration) (ids []uint64, maxNanotime int64, err error) {
231
-	rows, err := mysql.db.Query(`
313
+func (mysql *MySQL) selectCleanupIDs(ctx context.Context, age time.Duration) (ids []uint64, maxNanotime int64, err error) {
314
+	rows, err := mysql.db.QueryContext(ctx, `
232 315
 		SELECT history.id, sequence.nanotime
233 316
 		FROM history
234 317
 		LEFT JOIN sequence ON history.id = sequence.history_id
@@ -266,6 +349,109 @@ func (mysql *MySQL) selectCleanupIDs(age time.Duration) (ids []uint64, maxNanoti
266 349
 	return
267 350
 }
268 351
 
352
+// wait for forget queue items and process them one by one
353
+func (mysql *MySQL) forgetLoop() {
354
+	defer func() {
355
+		if r := recover(); r != nil {
356
+			mysql.logger.Error("mysql",
357
+				fmt.Sprintf("Panic in forget routine: %v\n%s", r, debug.Stack()))
358
+			time.Sleep(cleanupPauseTime)
359
+			go mysql.forgetLoop()
360
+		}
361
+	}()
362
+
363
+	for {
364
+		for {
365
+			found, err := mysql.doForget()
366
+			mysql.logError("error processing forget", err)
367
+			if err != nil {
368
+				time.Sleep(cleanupPauseTime)
369
+			}
370
+			if !found {
371
+				break
372
+			}
373
+		}
374
+
375
+		<-mysql.wakeForgetter
376
+	}
377
+}
378
+
379
+// dequeue an item from the forget queue and process it
380
+func (mysql *MySQL) doForget() (found bool, err error) {
381
+	id, account, err := func() (id int64, account string, err error) {
382
+		ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
383
+		defer cancel()
384
+
385
+		row := mysql.db.QueryRowContext(ctx,
386
+			`SELECT forget.id, forget.account FROM forget LIMIT 1;`)
387
+		err = row.Scan(&id, &account)
388
+		if err == sql.ErrNoRows {
389
+			return 0, "", nil
390
+		}
391
+		return
392
+	}()
393
+
394
+	if err != nil || account == "" {
395
+		return false, err
396
+	}
397
+
398
+	found = true
399
+
400
+	var count int
401
+	for {
402
+		start := time.Now()
403
+		count, err = mysql.doForgetIteration(account)
404
+		elapsed := time.Since(start)
405
+		if err != nil {
406
+			return true, err
407
+		}
408
+		if count == 0 {
409
+			break
410
+		}
411
+		time.Sleep(elapsed)
412
+	}
413
+
414
+	mysql.logger.Debug("mysql", "forget complete for account", account)
415
+
416
+	ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
417
+	defer cancel()
418
+	_, err = mysql.db.ExecContext(ctx, `DELETE FROM forget where id = ?;`, id)
419
+	return
420
+}
421
+
422
+func (mysql *MySQL) doForgetIteration(account string) (count int, err error) {
423
+	ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
424
+	defer cancel()
425
+
426
+	rows, err := mysql.db.QueryContext(ctx, `
427
+		SELECT account_messages.history_id
428
+		FROM account_messages
429
+		WHERE account_messages.account = ?
430
+		LIMIT ?;`, account, cleanupRowLimit)
431
+	if err != nil {
432
+		return
433
+	}
434
+	defer rows.Close()
435
+
436
+	var ids []uint64
437
+	for rows.Next() {
438
+		var id uint64
439
+		err = rows.Scan(&id)
440
+		if err != nil {
441
+			return
442
+		}
443
+		ids = append(ids, id)
444
+	}
445
+
446
+	if len(ids) == 0 {
447
+		return
448
+	}
449
+
450
+	mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows from account %s", len(ids), account))
451
+	err = mysql.deleteHistoryIDs(ctx, ids)
452
+	return len(ids), err
453
+}
454
+
269 455
 func (mysql *MySQL) prepareStatements() (err error) {
270 456
 	mysql.insertHistory, err = mysql.db.Prepare(`INSERT INTO history
271 457
 		(data, msgid) VALUES (?, ?);`)
@@ -282,6 +468,11 @@ func (mysql *MySQL) prepareStatements() (err error) {
282 468
 	if err != nil {
283 469
 		return
284 470
 	}
471
+	mysql.insertAccountMessage, err = mysql.db.Prepare(`INSERT INTO account_messages
472
+		(history_id, account) VALUES (?, ?);`)
473
+	if err != nil {
474
+		return
475
+	}
285 476
 
286 477
 	return
287 478
 }
@@ -290,6 +481,10 @@ func (mysql *MySQL) getTimeout() time.Duration {
290 481
 	return time.Duration(atomic.LoadInt64(&mysql.timeout))
291 482
 }
292 483
 
484
+func (mysql *MySQL) isTrackingAccountMessages() bool {
485
+	return atomic.LoadUint32(&mysql.trackAccountMessages) != 0
486
+}
487
+
293 488
 func (mysql *MySQL) logError(context string, err error) (quit bool) {
294 489
 	if err != nil {
295 490
 		mysql.logger.Error("mysql", context, err.Error())
@@ -298,7 +493,27 @@ func (mysql *MySQL) logError(context string, err error) (quit bool) {
298 493
 	return false
299 494
 }
300 495
 
301
-func (mysql *MySQL) AddChannelItem(target string, item history.Item) (err error) {
496
+func (mysql *MySQL) Forget(account string) {
497
+	if mysql.db == nil || account == "" {
498
+		return
499
+	}
500
+
501
+	ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
502
+	defer cancel()
503
+
504
+	_, err := mysql.db.ExecContext(ctx, `INSERT INTO forget (account) VALUES (?);`, account)
505
+	if mysql.logError("can't insert into forget table", err) {
506
+		return
507
+	}
508
+
509
+	// wake up the forget goroutine if it's blocked:
510
+	select {
511
+	case mysql.wakeForgetter <- e{}:
512
+	default:
513
+	}
514
+}
515
+
516
+func (mysql *MySQL) AddChannelItem(target string, item history.Item, account string) (err error) {
302 517
 	if mysql.db == nil {
303 518
 		return
304 519
 	}
@@ -316,6 +531,15 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item) (err error)
316 531
 	}
317 532
 
318 533
 	err = mysql.insertSequenceEntry(ctx, target, item.Message.Time.UnixNano(), id)
534
+	if err != nil {
535
+		return
536
+	}
537
+
538
+	err = mysql.insertAccountMessageEntry(ctx, id, account)
539
+	if err != nil {
540
+		return
541
+	}
542
+
319 543
 	return
320 544
 }
321 545
 
@@ -354,6 +578,15 @@ func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64
354 578
 	return
355 579
 }
356 580
 
581
+func (mysql *MySQL) insertAccountMessageEntry(ctx context.Context, id int64, account string) (err error) {
582
+	if account == "" || !mysql.isTrackingAccountMessages() {
583
+		return
584
+	}
585
+	_, err = mysql.insertAccountMessage.ExecContext(ctx, id, account)
586
+	mysql.logError("could not insert account-message entry", err)
587
+	return
588
+}
589
+
357 590
 func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item history.Item) (err error) {
358 591
 	if mysql.db == nil {
359 592
 		return
@@ -399,10 +632,102 @@ func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipient
399 632
 		}
400 633
 	}
401 634
 
635
+	err = mysql.insertAccountMessageEntry(ctx, id, senderAccount)
636
+	if err != nil {
637
+		return
638
+	}
639
+
640
+	return
641
+}
642
+
643
+// note that accountName is the unfolded name
644
+func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
645
+	if mysql.db == nil {
646
+		return nil
647
+	}
648
+
649
+	ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
650
+	defer cancel()
651
+
652
+	_, id, data, err := mysql.lookupMsgid(ctx, msgid, true)
653
+	if err != nil {
654
+		return
655
+	}
656
+
657
+	if accountName != "*" {
658
+		var item history.Item
659
+		err = unmarshalItem(data, &item)
660
+		// delete if the entry is corrupt
661
+		if err == nil && item.AccountName != accountName {
662
+			return ErrDisallowed
663
+		}
664
+	}
665
+
666
+	err = mysql.deleteHistoryIDs(ctx, []uint64{id})
667
+	mysql.logError("couldn't delete msgid", err)
402 668
 	return
403 669
 }
404 670
 
405
-func (mysql *MySQL) msgidToTime(ctx context.Context, msgid string) (result time.Time, err error) {
671
+func (mysql *MySQL) Export(account string, writer io.Writer) {
672
+	if mysql.db == nil {
673
+		return
674
+	}
675
+
676
+	var err error
677
+	var lastSeen uint64
678
+	for {
679
+		rows := func() (count int) {
680
+			ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
681
+			defer cancel()
682
+
683
+			rows, rowsErr := mysql.db.QueryContext(ctx, `
684
+				SELECT account_messages.history_id, history.data, sequence.target FROM account_messages
685
+				INNER JOIN history ON history.id = account_messages.history_id
686
+				INNER JOIN sequence ON account_messages.history_id = sequence.history_id
687
+				WHERE account_messages.account = ? AND account_messages.history_id > ?
688
+				LIMIT ?`, account, lastSeen, cleanupRowLimit)
689
+			if rowsErr != nil {
690
+				err = rowsErr
691
+				return
692
+			}
693
+			defer rows.Close()
694
+			for rows.Next() {
695
+				var id uint64
696
+				var blob, jsonBlob []byte
697
+				var target string
698
+				var item history.Item
699
+				err = rows.Scan(&id, &blob, &target)
700
+				if err != nil {
701
+					return
702
+				}
703
+				err = unmarshalItem(blob, &item)
704
+				if err != nil {
705
+					return
706
+				}
707
+				item.CfCorrespondent = target
708
+				jsonBlob, err = json.Marshal(item)
709
+				if err != nil {
710
+					return
711
+				}
712
+				count++
713
+				if lastSeen < id {
714
+					lastSeen = id
715
+				}
716
+				writer.Write(jsonBlob)
717
+				writer.Write([]byte{'\n'})
718
+			}
719
+			return
720
+		}()
721
+		if rows == 0 || err != nil {
722
+			break
723
+		}
724
+	}
725
+
726
+	mysql.logError("could not export history", err)
727
+	return
728
+}
729
+
730
+func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
406 731
 	// in theory, we could optimize out a roundtrip to the database by using a subquery instead:
407 732
 	// sequence.nanotime > (
408 733
 	//     SELECT sequence.nanotime FROM sequence, history
@@ -415,15 +740,27 @@ func (mysql *MySQL) msgidToTime(ctx context.Context, msgid string) (result time.
415 740
 	if err != nil {
416 741
 		return
417 742
 	}
418
-	row := mysql.db.QueryRowContext(ctx, `
419
-		SELECT sequence.nanotime FROM sequence
743
+	cols := `sequence.nanotime`
744
+	if includeData {
745
+		cols = `sequence.nanotime, sequence.history_id, history.data`
746
+	}
747
+	row := mysql.db.QueryRowContext(ctx, fmt.Sprintf(`
748
+		SELECT %s FROM sequence
420 749
 		INNER JOIN history ON history.id = sequence.history_id
421
-		WHERE history.msgid = ? LIMIT 1;`, decoded)
750
+		WHERE history.msgid = ? LIMIT 1;`, cols), decoded)
422 751
 	var nanotime int64
423
-	err = row.Scan(&nanotime)
424
-	if mysql.logError("could not resolve msgid to time", err) {
752
+	if !includeData {
753
+		err = row.Scan(&nanotime)
754
+	} else {
755
+		err = row.Scan(&nanotime, &id, &data)
756
+	}
757
+	if err != sql.ErrNoRows {
758
+		mysql.logError("could not resolve msgid to time", err)
759
+	}
760
+	if err != nil {
425 761
 		return
426 762
 	}
763
+
427 764
 	result = time.Unix(0, nanotime).UTC()
428 765
 	return
429 766
 }
@@ -519,14 +856,14 @@ func (s *mySQLHistorySequence) Between(start, end history.Selector, limit int) (
519 856
 
520 857
 	startTime := start.Time
521 858
 	if start.Msgid != "" {
522
-		startTime, err = s.mysql.msgidToTime(ctx, start.Msgid)
859
+		startTime, _, _, err = s.mysql.lookupMsgid(ctx, start.Msgid, false)
523 860
 		if err != nil {
524 861
 			return nil, false, err
525 862
 		}
526 863
 	}
527 864
 	endTime := end.Time
528 865
 	if end.Msgid != "" {
529
-		endTime, err = s.mysql.msgidToTime(ctx, end.Msgid)
866
+		endTime, _, _, err = s.mysql.lookupMsgid(ctx, end.Msgid, false)
530 867
 		if err != nil {
531 868
 			return nil, false, err
532 869
 		}

+ 1
- 1
irc/nickname.go View File

@@ -80,7 +80,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
80 80
 	}
81 81
 
82 82
 	for _, channel := range client.Channels() {
83
-		channel.AddHistoryItem(histItem)
83
+		channel.AddHistoryItem(histItem, details.account)
84 84
 	}
85 85
 
86 86
 	if target.Registered() {

+ 1
- 1
irc/roleplay.go View File

@@ -91,7 +91,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
91 91
 			Type:    history.Privmsg,
92 92
 			Message: splitMessage,
93 93
 			Nick:    source,
94
-		})
94
+		}, client.Account())
95 95
 	} else {
96 96
 		target, err := CasefoldName(targetString)
97 97
 		user := server.clients.Get(target)

+ 70
- 0
irc/server.go View File

@@ -879,6 +879,76 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien
879 879
 	return
880 880
 }
881 881
 
882
+func (server *Server) ForgetHistory(accountName string) {
883
+	// sanity check
884
+	if accountName == "*" {
885
+		return
886
+	}
887
+
888
+	config := server.Config()
889
+	if !config.History.Enabled {
890
+		return
891
+	}
892
+
893
+	if cfAccount, err := CasefoldName(accountName); err == nil {
894
+		server.historyDB.Forget(cfAccount)
895
+	}
896
+
897
+	persistent := config.History.Persistent
898
+	if persistent.Enabled && persistent.UnregisteredChannels && persistent.RegisteredChannels == PersistentMandatory && persistent.DirectMessages == PersistentMandatory {
899
+		return
900
+	}
901
+
902
+	predicate := func(item *history.Item) bool { return item.AccountName == accountName }
903
+
904
+	for _, channel := range server.channels.Channels() {
905
+		channel.history.Delete(predicate)
906
+	}
907
+
908
+	for _, client := range server.clients.AllClients() {
909
+		client.history.Delete(predicate)
910
+	}
911
+}
912
+
913
+// deletes a message. target is a hint about what buffer it's in (not required for
914
+// persistent history, where all the msgids are indexed together). if accountName
915
+// is anything other than "*", it must match the recorded AccountName of the message
916
+func (server *Server) DeleteMessage(target, msgid, accountName string) (err error) {
917
+	config := server.Config()
918
+	var hist *history.Buffer
919
+
920
+	if target != "" {
921
+		if target[0] == '#' {
922
+			channel := server.channels.Get(target)
923
+			if channel != nil {
924
+				if status, _ := channel.historyStatus(config); status == HistoryEphemeral {
925
+					hist = &channel.history
926
+				}
927
+			}
928
+		} else {
929
+			client := server.clients.Get(target)
930
+			if client != nil {
931
+				if status, _ := client.historyStatus(config); status == HistoryEphemeral {
932
+					hist = &client.history
933
+				}
934
+			}
935
+		}
936
+	}
937
+
938
+	if hist == nil {
939
+		err = server.historyDB.DeleteMsgid(msgid, accountName)
940
+	} else {
941
+		count := hist.Delete(func(item *history.Item) bool {
942
+			return item.Message.Msgid == msgid && (accountName == "*" || item.AccountName == accountName)
943
+		})
944
+		if count == 0 {
945
+			err = errNoop
946
+		}
947
+	}
948
+
949
+	return
950
+}
951
+
882 952
 // elistMatcher takes and matches ELIST conditions
883 953
 type elistMatcher struct {
884 954
 	MinClientsActive bool

+ 7
- 0
irc/services.go View File

@@ -82,6 +82,13 @@ var OragonoServices = map[string]*ircService{
82 82
 		Commands:       hostservCommands,
83 83
 		HelpBanner:     hostservHelp,
84 84
 	},
85
+	"histserv": {
86
+		Name:           "HistServ",
87
+		ShortName:      "HISTSERV",
88
+		CommandAliases: []string{"HISTSERV"},
89
+		Commands:       histservCommands,
90
+		HelpBanner:     histservHelp,
91
+	},
85 92
 }
86 93
 
87 94
 // all service commands at the protocol level, by uppercase command name

+ 17
- 1
oragono.yaml View File

@@ -280,6 +280,10 @@ server:
280 280
     secure-nets:
281 281
         # - "10.0.0.0/8"
282 282
 
283
+    # oragono will write files to disk under certain circumstances, e.g.,
284
+    # CPU profiling or data export. by default, these files will be written
285
+    # to the working directory. set this to customize:
286
+    # output-path: "/home/oragono/out"
283 287
 
284 288
 # account options
285 289
 accounts:
@@ -577,6 +581,7 @@ oper-classes:
577 581
             - "samode"
578 582
             - "vhosts"
579 583
             - "chanreg"
584
+            - "history"
580 585
 
581 586
 # ircd operators
582 587
 opers:
@@ -772,7 +777,8 @@ roleplay:
772 777
     # add the real nickname, in parentheses, to the end of every roleplay message?
773 778
     add-suffix: true
774 779
 
775
-# message history tracking, for the RESUME extension and possibly other uses in future
780
+# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
781
+# various autoreplay features, and the resume extension
776 782
 history:
777 783
     # should we store messages for later playback?
778 784
     # by default, messages are stored in RAM only; they do not persist
@@ -841,3 +847,13 @@ history:
841 847
         # if you enable this, strict nickname reservation is strongly recommended
842 848
         # as well.
843 849
         direct-messages: "opt-out"
850
+
851
+    # options to control how messages are stored and deleted:
852
+    retention:
853
+        # allow users to delete their own messages from history?
854
+        allow-individual-delete: false
855
+
856
+        # if persistent history is enabled, create additional index tables,
857
+        # allowing deletion of JSON export of an account's messages. this
858
+        # may be needed for compliance with data privacy regulations.
859
+        enable-account-indexing: false

Loading…
Cancel
Save