mirror of
https://github.com/ergochat/ergo.git
synced 2024-12-22 18:52:41 +01:00
commit
a46c0eed9f
@ -259,6 +259,10 @@ server:
|
||||
secure-nets:
|
||||
# - "10.0.0.0/8"
|
||||
|
||||
# oragono will write files to disk under certain circumstances, e.g.,
|
||||
# CPU profiling or data export. by default, these files will be written
|
||||
# to the working directory. set this to customize:
|
||||
# output-path: "/home/oragono/out"
|
||||
|
||||
# account options
|
||||
accounts:
|
||||
@ -556,6 +560,7 @@ oper-classes:
|
||||
- "samode"
|
||||
- "vhosts"
|
||||
- "chanreg"
|
||||
- "history"
|
||||
|
||||
# ircd operators
|
||||
opers:
|
||||
@ -751,7 +756,8 @@ roleplay:
|
||||
# add the real nickname, in parentheses, to the end of every roleplay message?
|
||||
add-suffix: true
|
||||
|
||||
# message history tracking, for the RESUME extension and possibly other uses in future
|
||||
# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
|
||||
# various autoreplay features, and the resume extension
|
||||
history:
|
||||
# should we store messages for later playback?
|
||||
# by default, messages are stored in RAM only; they do not persist
|
||||
@ -820,3 +826,13 @@ history:
|
||||
# if you enable this, strict nickname reservation is strongly recommended
|
||||
# as well.
|
||||
direct-messages: "opt-out"
|
||||
|
||||
# options to control how messages are stored and deleted:
|
||||
retention:
|
||||
# allow users to delete their own messages from history?
|
||||
allow-individual-delete: false
|
||||
|
||||
# if persistent history is enabled, create additional index tables,
|
||||
# allowing deletion of JSON export of an account's messages. this
|
||||
# may be needed for compliance with data privacy regulations.
|
||||
enable-account-indexing: false
|
||||
|
@ -1103,6 +1103,30 @@ func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount,
|
||||
return
|
||||
}
|
||||
|
||||
// look up the unfolded version of an account name, possibly after deletion
|
||||
func (am *AccountManager) AccountToAccountName(account string) (result string) {
|
||||
casefoldedAccount, err := CasefoldName(account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
|
||||
accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
|
||||
|
||||
am.server.store.View(func(tx *buntdb.Tx) error {
|
||||
if name, err := tx.Get(accountNameKey); err == nil {
|
||||
result = name
|
||||
return nil
|
||||
}
|
||||
if name, err := tx.Get(unregisteredKey); err == nil {
|
||||
result = name
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName string) (result ClientAccount, err error) {
|
||||
result.Name = raw.Name
|
||||
result.NameCasefolded = cfName
|
||||
|
@ -20,10 +20,6 @@ import (
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
histServMask = "HistServ!HistServ@localhost"
|
||||
)
|
||||
|
||||
type ChannelSettings struct {
|
||||
History HistoryStatus
|
||||
}
|
||||
@ -641,14 +637,14 @@ func channelHistoryStatus(config *Config, registered bool, storedStatus HistoryS
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *Channel) AddHistoryItem(item history.Item) (err error) {
|
||||
func (channel *Channel) AddHistoryItem(item history.Item, account string) (err error) {
|
||||
if !item.IsStorable() {
|
||||
return
|
||||
}
|
||||
|
||||
status, target := channel.historyStatus(channel.server.Config())
|
||||
if status == HistoryPersistent {
|
||||
err = channel.server.historyDB.AddChannelItem(target, item)
|
||||
err = channel.server.historyDB.AddChannelItem(target, item, account)
|
||||
} else if status == HistoryEphemeral {
|
||||
channel.history.Add(item)
|
||||
}
|
||||
@ -746,7 +742,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
|
||||
Message: message,
|
||||
}
|
||||
histItem.Params[0] = details.realname
|
||||
channel.AddHistoryItem(histItem)
|
||||
channel.AddHistoryItem(histItem, details.account)
|
||||
}
|
||||
|
||||
client.addChannel(channel, rb == nil)
|
||||
@ -902,7 +898,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
|
||||
Nick: details.nickMask,
|
||||
AccountName: details.accountName,
|
||||
Message: splitMessage,
|
||||
})
|
||||
}, details.account)
|
||||
|
||||
client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", details.nick, chname))
|
||||
}
|
||||
@ -1165,7 +1161,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
|
||||
Nick: details.nickMask,
|
||||
AccountName: details.accountName,
|
||||
Message: message,
|
||||
})
|
||||
}, details.account)
|
||||
|
||||
channel.MarkDirty(IncludeTopic)
|
||||
}
|
||||
@ -1222,8 +1218,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||
return
|
||||
}
|
||||
|
||||
nickmask := client.NickMaskString()
|
||||
account := client.AccountName()
|
||||
details := client.Details()
|
||||
chname := channel.Name()
|
||||
|
||||
// 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
|
||||
tagsToUse = clientOnlyTags
|
||||
}
|
||||
if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
|
||||
rb.AddFromClient(message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
|
||||
rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
|
||||
} else {
|
||||
rb.AddSplitMessageFromClient(nickmask, account, tagsToUse, command, chname, message)
|
||||
rb.AddSplitMessageFromClient(details.nickMask, details.accountName, tagsToUse, command, chname, message)
|
||||
}
|
||||
}
|
||||
// send echo-message to other connected sessions
|
||||
@ -1253,9 +1248,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||
tagsToUse = clientOnlyTags
|
||||
}
|
||||
if histType == history.Tagmsg && session.capabilities.Has(caps.MessageTags) {
|
||||
session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
|
||||
session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
|
||||
} else if histType != history.Tagmsg {
|
||||
session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message)
|
||||
session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1282,9 +1277,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||
}
|
||||
|
||||
if histType == history.Tagmsg {
|
||||
session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
|
||||
session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
|
||||
} else {
|
||||
session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message)
|
||||
session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1294,10 +1289,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||
channel.AddHistoryItem(history.Item{
|
||||
Type: histType,
|
||||
Message: message,
|
||||
Nick: nickmask,
|
||||
AccountName: account,
|
||||
Nick: details.nickMask,
|
||||
AccountName: details.accountName,
|
||||
Tags: clientOnlyTags,
|
||||
})
|
||||
}, details.account)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1391,28 +1386,27 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
|
||||
}
|
||||
|
||||
message := utils.MakeMessage(comment)
|
||||
clientMask := client.NickMaskString()
|
||||
clientAccount := client.AccountName()
|
||||
details := client.Details()
|
||||
|
||||
targetNick := target.Nick()
|
||||
chname := channel.Name()
|
||||
for _, member := range channel.Members() {
|
||||
for _, session := range member.Sessions() {
|
||||
if session != rb.session {
|
||||
session.sendFromClientInternal(false, message.Time, message.Msgid, clientMask, clientAccount, nil, "KICK", chname, targetNick, comment)
|
||||
session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
rb.Add(nil, clientMask, "KICK", chname, targetNick, comment)
|
||||
rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
|
||||
|
||||
histItem := history.Item{
|
||||
Type: history.Kick,
|
||||
Nick: clientMask,
|
||||
AccountName: target.AccountName(),
|
||||
Nick: details.nickMask,
|
||||
AccountName: details.accountName,
|
||||
Message: message,
|
||||
}
|
||||
histItem.Params[0] = targetNick
|
||||
channel.AddHistoryItem(histItem)
|
||||
channel.AddHistoryItem(histItem, details.account)
|
||||
|
||||
channel.Quit(target)
|
||||
}
|
||||
|
@ -255,7 +255,7 @@ func csAmodeHandler(server *Server, client *Client, command string, params []str
|
||||
if member.Account() == change.Arg {
|
||||
applied, change := channel.applyModeToMember(client, change, rb)
|
||||
if applied {
|
||||
announceCmodeChanges(channel, modes.ModeChanges{change}, chanservMask, "*", rb)
|
||||
announceCmodeChanges(channel, modes.ModeChanges{change}, chanservMask, "*", "", rb)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -302,7 +302,7 @@ func csOpHandler(server *Server, client *Client, command string, params []string
|
||||
},
|
||||
rb)
|
||||
if applied {
|
||||
announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", rb)
|
||||
announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", "", rb)
|
||||
}
|
||||
|
||||
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 []
|
||||
},
|
||||
rb)
|
||||
if applied {
|
||||
announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", rb)
|
||||
announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", "", rb)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1277,7 +1277,7 @@ func (client *Client) destroy(session *Session) {
|
||||
// use a defer here to avoid writing to mysql while holding the destroy semaphore:
|
||||
defer func() {
|
||||
for _, channel := range channels {
|
||||
channel.AddHistoryItem(quitItem)
|
||||
channel.AddHistoryItem(quitItem, details.account)
|
||||
}
|
||||
}()
|
||||
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@ -511,6 +512,7 @@ type Config struct {
|
||||
supportedCaps *caps.Set
|
||||
capValues caps.Values
|
||||
Casemapping Casemapping
|
||||
OutputPath string `yaml:"output-path"`
|
||||
}
|
||||
|
||||
Roleplay struct {
|
||||
@ -590,6 +592,10 @@ type Config struct {
|
||||
RegisteredChannels PersistentStatus `yaml:"registered-channels"`
|
||||
DirectMessages PersistentStatus `yaml:"direct-messages"`
|
||||
}
|
||||
Retention struct {
|
||||
AllowIndividualDelete bool `yaml:"allow-individual-delete"`
|
||||
EnableAccountIndexing bool `yaml:"enable-account-indexing"`
|
||||
}
|
||||
}
|
||||
|
||||
Filename string
|
||||
@ -1111,6 +1117,7 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||
config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
|
||||
|
||||
config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
|
||||
config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
|
||||
|
||||
config.Server.Cloaks.Initialize()
|
||||
if config.Server.Cloaks.Enabled {
|
||||
@ -1133,6 +1140,10 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (config *Config) getOutputPath(filename string) string {
|
||||
return filepath.Join(config.Server.OutputPath, filename)
|
||||
}
|
||||
|
||||
// setISupport sets up our RPL_ISUPPORT reply.
|
||||
func (config *Config) generateISupport() (err error) {
|
||||
maxTargetsString := strconv.Itoa(maxTargets)
|
||||
|
@ -676,7 +676,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
|
||||
rb.Notice(fmt.Sprintf("num goroutines: %d", count))
|
||||
|
||||
case "PROFILEHEAP":
|
||||
profFile := "oragono.mprof"
|
||||
profFile := server.Config().getOutputPath("oragono.mprof")
|
||||
file, err := os.Create(profFile)
|
||||
if err != nil {
|
||||
rb.Notice(fmt.Sprintf("error: %s", err))
|
||||
@ -687,7 +687,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
|
||||
rb.Notice(fmt.Sprintf("written to %s", profFile))
|
||||
|
||||
case "STARTCPUPROFILE":
|
||||
profFile := "oragono.prof"
|
||||
profFile := server.Config().getOutputPath("oragono.prof")
|
||||
file, err := os.Create(profFile)
|
||||
if err != nil {
|
||||
rb.Notice(fmt.Sprintf("error: %s", err))
|
||||
@ -934,50 +934,17 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
|
||||
return false
|
||||
}
|
||||
|
||||
target := msg.Params[0]
|
||||
if strings.ToLower(target) == "me" {
|
||||
target = "*"
|
||||
}
|
||||
channel, sequence, err := server.GetHistorySequence(nil, client, target)
|
||||
items, channel, err := easySelectHistory(server, client, msg.Params)
|
||||
|
||||
if sequence == nil || err != nil {
|
||||
// whatever
|
||||
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
|
||||
if err == errNoSuchChannel {
|
||||
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel"))
|
||||
return false
|
||||
} else if err != nil {
|
||||
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Could not retrieve history"))
|
||||
return false
|
||||
}
|
||||
|
||||
var duration time.Duration
|
||||
maxChathistoryLimit := config.History.ChathistoryMax
|
||||
limit := 100
|
||||
if maxChathistoryLimit < limit {
|
||||
limit = maxChathistoryLimit
|
||||
}
|
||||
if len(msg.Params) > 1 {
|
||||
providedLimit, err := strconv.Atoi(msg.Params[1])
|
||||
if err == nil && providedLimit != 0 {
|
||||
limit = providedLimit
|
||||
if maxChathistoryLimit < limit {
|
||||
limit = maxChathistoryLimit
|
||||
}
|
||||
} else if err != nil {
|
||||
duration, err = time.ParseDuration(msg.Params[1])
|
||||
if err == nil {
|
||||
limit = maxChathistoryLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var items []history.Item
|
||||
if duration == 0 {
|
||||
items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
|
||||
} else {
|
||||
now := time.Now().UTC()
|
||||
start := history.Selector{Time: now}
|
||||
end := history.Selector{Time: now.Add(-duration)}
|
||||
items, _, err = sequence.Between(start, end, limit)
|
||||
}
|
||||
|
||||
if err == nil && len(items) != 0 {
|
||||
if len(items) != 0 {
|
||||
if channel != nil {
|
||||
channel.replayHistoryItems(rb, items, false)
|
||||
} else {
|
||||
@ -1533,12 +1500,12 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
|
||||
// process mode changes, include list operations (an empty set of changes does a list)
|
||||
applied := channel.ApplyChannelModeChanges(client, msg.Command == "SAMODE", changes, rb)
|
||||
details := client.Details()
|
||||
announceCmodeChanges(channel, applied, details.nickMask, details.accountName, rb)
|
||||
announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, rb)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName string, rb *ResponseBuffer) {
|
||||
func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName, account string, rb *ResponseBuffer) {
|
||||
// send out changes
|
||||
if len(applied) > 0 {
|
||||
message := utils.MakeMessage("")
|
||||
@ -1560,7 +1527,7 @@ func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, a
|
||||
Nick: source,
|
||||
AccountName: accountName,
|
||||
Message: message,
|
||||
})
|
||||
}, account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -284,6 +284,32 @@ func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int
|
||||
return
|
||||
}
|
||||
|
||||
// Delete deletes messages matching some predicate.
|
||||
func (list *Buffer) Delete(predicate Predicate) (count int) {
|
||||
list.Lock()
|
||||
defer list.Unlock()
|
||||
|
||||
if list.start == -1 || len(list.buffer) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pos := list.start
|
||||
stop := list.prev(list.end)
|
||||
|
||||
for {
|
||||
if predicate(&list.buffer[pos]) {
|
||||
list.buffer[pos] = Item{}
|
||||
count++
|
||||
}
|
||||
if pos == stop {
|
||||
break
|
||||
}
|
||||
pos = list.next(pos)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// latest returns the items most recently added, up to `limit`. If `limit` is 0,
|
||||
// it returns all items.
|
||||
func (list *Buffer) latest(limit int) (results []Item) {
|
||||
|
244
irc/histserv.go
Normal file
244
irc/histserv.go
Normal file
@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// released under the MIT license
|
||||
|
||||
package irc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oragono/oragono/irc/history"
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
histservHelp = `HistServ provides commands related to history.`
|
||||
histServMask = "HistServ!HistServ@localhost"
|
||||
)
|
||||
|
||||
func histservEnabled(config *Config) bool {
|
||||
return config.History.Enabled
|
||||
}
|
||||
|
||||
func historyComplianceEnabled(config *Config) bool {
|
||||
return config.History.Enabled && config.History.Persistent.Enabled && config.History.Retention.EnableAccountIndexing
|
||||
}
|
||||
|
||||
var (
|
||||
histservCommands = map[string]*serviceCommand{
|
||||
"forget": {
|
||||
handler: histservForgetHandler,
|
||||
help: `Syntax: $bFORGET <account>$b
|
||||
|
||||
FORGET deletes all history messages sent by an account.`,
|
||||
helpShort: `$bFORGET$b deletes all history messages sent by an account.`,
|
||||
capabs: []string{"history"},
|
||||
enabled: histservEnabled,
|
||||
minParams: 1,
|
||||
maxParams: 1,
|
||||
},
|
||||
"delete": {
|
||||
handler: histservDeleteHandler,
|
||||
help: `Syntax: $bDELETE [target] <msgid>$b
|
||||
|
||||
DELETE deletes an individual message by its msgid. The target is a channel
|
||||
name or nickname; depending on the history implementation, this may or may not
|
||||
be necessary to locate the message.`,
|
||||
helpShort: `$bDELETE$b deletes an individual message by its msgid.`,
|
||||
enabled: histservEnabled,
|
||||
minParams: 1,
|
||||
maxParams: 2,
|
||||
},
|
||||
"export": {
|
||||
handler: histservExportHandler,
|
||||
help: `Syntax: $bEXPORT <account>$b
|
||||
|
||||
EXPORT exports all messages sent by an account as JSON. This can be used at
|
||||
the request of the account holder.`,
|
||||
helpShort: `$bEXPORT$b exports all messages sent by an account as JSON.`,
|
||||
enabled: historyComplianceEnabled,
|
||||
capabs: []string{"history"},
|
||||
minParams: 1,
|
||||
maxParams: 1,
|
||||
},
|
||||
"play": {
|
||||
handler: histservPlayHandler,
|
||||
help: `Syntax: $bPLAY <target> [limit]$b
|
||||
|
||||
PLAY plays back history messages, rendering them into direct messages from
|
||||
HistServ. 'target' is a channel name (or 'me' for direct messages), and 'limit'
|
||||
is a message count or a time duration. Note that message playback may be
|
||||
incomplete or degraded, relative to direct playback from /HISTORY or
|
||||
CHATHISTORY.`,
|
||||
helpShort: `$bPLAY$b plays back history messages.`,
|
||||
enabled: histservEnabled,
|
||||
minParams: 1,
|
||||
maxParams: 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// histNotice sends the client a notice from HistServ
|
||||
func histNotice(rb *ResponseBuffer, text string) {
|
||||
rb.Add(nil, histServMask, "NOTICE", rb.target.Nick(), text)
|
||||
}
|
||||
|
||||
func histservForgetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
accountName := server.accounts.AccountToAccountName(params[0])
|
||||
if accountName == "" {
|
||||
histNotice(rb, client.t("Could not look up account name, proceeding anyway"))
|
||||
accountName = params[0]
|
||||
}
|
||||
|
||||
server.ForgetHistory(accountName)
|
||||
|
||||
histNotice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
|
||||
}
|
||||
|
||||
func histservDeleteHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
var target, msgid string
|
||||
if len(params) == 1 {
|
||||
msgid = params[0]
|
||||
} else {
|
||||
target, msgid = params[0], params[1]
|
||||
}
|
||||
|
||||
accountName := "*"
|
||||
hasPrivs := client.HasRoleCapabs("history")
|
||||
if !hasPrivs {
|
||||
accountName = client.AccountName()
|
||||
if !(server.Config().History.Retention.AllowIndividualDelete && accountName != "*") {
|
||||
hsNotice(rb, client.t("Insufficient privileges"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := server.DeleteMessage(target, msgid, accountName)
|
||||
if err == nil {
|
||||
hsNotice(rb, client.t("Successfully deleted message"))
|
||||
} else {
|
||||
if hasPrivs {
|
||||
hsNotice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
|
||||
} else {
|
||||
hsNotice(rb, client.t("Could not delete message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func histservExportHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
cfAccount, err := CasefoldName(params[0])
|
||||
if err != nil {
|
||||
histNotice(rb, client.t("Invalid account name"))
|
||||
return
|
||||
}
|
||||
|
||||
config := server.Config()
|
||||
// don't include the account name in the filename because of escaping concerns
|
||||
filename := fmt.Sprintf("%s-%s.json", utils.GenerateSecretToken(), time.Now().UTC().Format(IRCv3TimestampFormat))
|
||||
pathname := config.getOutputPath(filename)
|
||||
outfile, err := os.Create(pathname)
|
||||
if err != nil {
|
||||
hsNotice(rb, fmt.Sprintf(client.t("Error opening export file: %v"), err))
|
||||
} else {
|
||||
hsNotice(rb, fmt.Sprintf(client.t("Started exporting data for account %[1]s to file %[2]s"), cfAccount, filename))
|
||||
}
|
||||
|
||||
go histservExportAndNotify(server, cfAccount, outfile, filename, client.Nick())
|
||||
}
|
||||
|
||||
func histservExportAndNotify(server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
server.logger.Error("history",
|
||||
fmt.Sprintf("Panic in history export routine: %v\n%s", r, debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
defer outfile.Close()
|
||||
writer := bufio.NewWriter(outfile)
|
||||
defer writer.Flush()
|
||||
|
||||
server.historyDB.Export(cfAccount, writer)
|
||||
|
||||
client := server.clients.Get(alertNick)
|
||||
if client != nil && client.HasRoleCapabs("history") {
|
||||
client.Send(nil, histServMask, "NOTICE", client.Nick(), fmt.Sprintf(client.t("Data export for %[1]s completed and written to %[2]s"), cfAccount, filename))
|
||||
}
|
||||
}
|
||||
|
||||
func histservPlayHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||
items, _, err := easySelectHistory(server, client, params)
|
||||
if err != nil {
|
||||
hsNotice(rb, client.t("Could not retrieve history"))
|
||||
return
|
||||
}
|
||||
|
||||
playMessage := func(timestamp time.Time, nick, message string) {
|
||||
hsNotice(rb, fmt.Sprintf("%s <%s> %s", timestamp.Format("15:04:05"), stripMaskFromNick(nick), message))
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
// TODO: support a few more of these, maybe JOIN/PART/QUIT
|
||||
if item.Type != history.Privmsg && item.Type != history.Notice {
|
||||
continue
|
||||
}
|
||||
if len(item.Message.Split) == 0 {
|
||||
playMessage(item.Message.Time, item.Nick, item.Message.Message)
|
||||
} else {
|
||||
for _, pair := range item.Message.Split {
|
||||
playMessage(item.Message.Time, item.Nick, pair.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hsNotice(rb, client.t("End of history playback"))
|
||||
}
|
||||
|
||||
// handles parameter parsing and history queries for /HISTORY and /HISTSERV PLAY
|
||||
func easySelectHistory(server *Server, client *Client, params []string) (items []history.Item, channel *Channel, err error) {
|
||||
target := params[0]
|
||||
if strings.ToLower(target) == "me" {
|
||||
target = "*"
|
||||
}
|
||||
channel, sequence, err := server.GetHistorySequence(nil, client, target)
|
||||
|
||||
if sequence == nil || err != nil {
|
||||
return nil, nil, errNoSuchChannel
|
||||
}
|
||||
|
||||
var duration time.Duration
|
||||
maxChathistoryLimit := server.Config().History.ChathistoryMax
|
||||
limit := 100
|
||||
if maxChathistoryLimit < limit {
|
||||
limit = maxChathistoryLimit
|
||||
}
|
||||
if len(params) > 1 {
|
||||
providedLimit, err := strconv.Atoi(params[1])
|
||||
if err == nil && providedLimit != 0 {
|
||||
limit = providedLimit
|
||||
if maxChathistoryLimit < limit {
|
||||
limit = maxChathistoryLimit
|
||||
}
|
||||
} else if err != nil {
|
||||
duration, err = time.ParseDuration(params[1])
|
||||
if err == nil {
|
||||
limit = maxChathistoryLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if duration == 0 {
|
||||
items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
|
||||
} else {
|
||||
now := time.Now().UTC()
|
||||
start := history.Selector{Time: now}
|
||||
end := history.Selector{Time: now.Add(-duration)}
|
||||
items, _, err = sequence.Between(start, end, limit)
|
||||
}
|
||||
return
|
||||
}
|
@ -18,5 +18,6 @@ type Config struct {
|
||||
Timeout time.Duration
|
||||
|
||||
// XXX these are copied from elsewhere in the config:
|
||||
ExpireTime time.Duration
|
||||
ExpireTime time.Duration
|
||||
TrackAccountMessages bool
|
||||
}
|
||||
|
@ -7,7 +7,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -19,6 +22,10 @@ import (
|
||||
"github.com/oragono/oragono/irc/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDisallowed = errors.New("disallowed")
|
||||
)
|
||||
|
||||
const (
|
||||
// maximum length in bytes of any message target (nickname or channel name) in its
|
||||
// canonicalized (i.e., casefolded) state:
|
||||
@ -27,30 +34,46 @@ const (
|
||||
// latest schema of the db
|
||||
latestDbSchema = "2"
|
||||
keySchemaVersion = "db.version"
|
||||
cleanupRowLimit = 50
|
||||
cleanupPauseTime = 10 * time.Minute
|
||||
// minor version indicates rollback-safe upgrades, i.e.,
|
||||
// you can downgrade oragono and everything will work
|
||||
latestDbMinorVersion = "1"
|
||||
keySchemaMinorVersion = "db.minorversion"
|
||||
cleanupRowLimit = 50
|
||||
cleanupPauseTime = 10 * time.Minute
|
||||
)
|
||||
|
||||
type MySQL struct {
|
||||
timeout int64
|
||||
db *sql.DB
|
||||
logger *logger.Manager
|
||||
type e struct{}
|
||||
|
||||
insertHistory *sql.Stmt
|
||||
insertSequence *sql.Stmt
|
||||
insertConversation *sql.Stmt
|
||||
type MySQL struct {
|
||||
timeout int64
|
||||
trackAccountMessages uint32
|
||||
db *sql.DB
|
||||
logger *logger.Manager
|
||||
|
||||
insertHistory *sql.Stmt
|
||||
insertSequence *sql.Stmt
|
||||
insertConversation *sql.Stmt
|
||||
insertAccountMessage *sql.Stmt
|
||||
|
||||
stateMutex sync.Mutex
|
||||
config Config
|
||||
|
||||
wakeForgetter chan e
|
||||
}
|
||||
|
||||
func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
|
||||
mysql.logger = logger
|
||||
mysql.wakeForgetter = make(chan e, 1)
|
||||
mysql.SetConfig(config)
|
||||
}
|
||||
|
||||
func (mysql *MySQL) SetConfig(config Config) {
|
||||
atomic.StoreInt64(&mysql.timeout, int64(config.Timeout))
|
||||
var trackAccountMessages uint32
|
||||
if config.TrackAccountMessages {
|
||||
trackAccountMessages = 1
|
||||
}
|
||||
atomic.StoreUint32(&mysql.trackAccountMessages, trackAccountMessages)
|
||||
mysql.stateMutex.Lock()
|
||||
mysql.config = config
|
||||
mysql.stateMutex.Unlock()
|
||||
@ -85,6 +108,7 @@ func (m *MySQL) Open() (err error) {
|
||||
}
|
||||
|
||||
go m.cleanupLoop()
|
||||
go m.forgetLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -109,14 +133,35 @@ func (mysql *MySQL) fixSchemas() (err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
} else if err == nil && schema != latestDbSchema {
|
||||
// TODO figure out what to do about schema changes
|
||||
return &utils.IncompatibleSchemaError{CurrentVersion: schema, RequiredVersion: latestDbSchema}
|
||||
} else {
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
var minorVersion string
|
||||
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
|
||||
if err == sql.ErrNoRows {
|
||||
// XXX for now, the only minor version upgrade is the account tracking tables
|
||||
err = mysql.createComplianceTables()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else if err == nil && minorVersion != latestDbMinorVersion {
|
||||
// TODO: if minorVersion < latestDbMinorVersion, upgrade,
|
||||
// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) createTables() (err error) {
|
||||
@ -155,6 +200,32 @@ func (mysql *MySQL) createTables() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = mysql.createComplianceTables()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mysql *MySQL) createComplianceTables() (err error) {
|
||||
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE account_messages (
|
||||
history_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
account VARBINARY(%[1]d) NOT NULL,
|
||||
KEY (account, history_id)
|
||||
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE forget (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
account VARBINARY(%[1]d) NOT NULL
|
||||
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -191,7 +262,10 @@ func (mysql *MySQL) cleanupLoop() {
|
||||
}
|
||||
|
||||
func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
|
||||
ids, maxNanotime, err := mysql.selectCleanupIDs(age)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
||||
defer cancel()
|
||||
|
||||
ids, maxNanotime, err := mysql.selectCleanupIDs(ctx, age)
|
||||
if len(ids) == 0 {
|
||||
mysql.logger.Debug("mysql", "found no rows to clean up")
|
||||
return
|
||||
@ -199,6 +273,10 @@ func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
|
||||
|
||||
mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime)))
|
||||
|
||||
return len(ids), mysql.deleteHistoryIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func (mysql *MySQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err error) {
|
||||
// can't use ? binding for a variable number of arguments, build the IN clause manually
|
||||
var inBuf bytes.Buffer
|
||||
inBuf.WriteByte('(')
|
||||
@ -210,25 +288,30 @@ func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
|
||||
}
|
||||
inBuf.WriteRune(')')
|
||||
|
||||
_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
|
||||
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
|
||||
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
|
||||
if mysql.isTrackingAccountMessages() {
|
||||
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM account_messages WHERE history_id in %s;`, inBuf.Bytes()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
count = len(ids)
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) selectCleanupIDs(age time.Duration) (ids []uint64, maxNanotime int64, err error) {
|
||||
rows, err := mysql.db.Query(`
|
||||
func (mysql *MySQL) selectCleanupIDs(ctx context.Context, age time.Duration) (ids []uint64, maxNanotime int64, err error) {
|
||||
rows, err := mysql.db.QueryContext(ctx, `
|
||||
SELECT history.id, sequence.nanotime
|
||||
FROM history
|
||||
LEFT JOIN sequence ON history.id = sequence.history_id
|
||||
@ -266,6 +349,109 @@ func (mysql *MySQL) selectCleanupIDs(age time.Duration) (ids []uint64, maxNanoti
|
||||
return
|
||||
}
|
||||
|
||||
// wait for forget queue items and process them one by one
|
||||
func (mysql *MySQL) forgetLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mysql.logger.Error("mysql",
|
||||
fmt.Sprintf("Panic in forget routine: %v\n%s", r, debug.Stack()))
|
||||
time.Sleep(cleanupPauseTime)
|
||||
go mysql.forgetLoop()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
for {
|
||||
found, err := mysql.doForget()
|
||||
mysql.logError("error processing forget", err)
|
||||
if err != nil {
|
||||
time.Sleep(cleanupPauseTime)
|
||||
}
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
<-mysql.wakeForgetter
|
||||
}
|
||||
}
|
||||
|
||||
// dequeue an item from the forget queue and process it
|
||||
func (mysql *MySQL) doForget() (found bool, err error) {
|
||||
id, account, err := func() (id int64, account string, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
||||
defer cancel()
|
||||
|
||||
row := mysql.db.QueryRowContext(ctx,
|
||||
`SELECT forget.id, forget.account FROM forget LIMIT 1;`)
|
||||
err = row.Scan(&id, &account)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, "", nil
|
||||
}
|
||||
return
|
||||
}()
|
||||
|
||||
if err != nil || account == "" {
|
||||
return false, err
|
||||
}
|
||||
|
||||
found = true
|
||||
|
||||
var count int
|
||||
for {
|
||||
start := time.Now()
|
||||
count, err = mysql.doForgetIteration(account)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(elapsed)
|
||||
}
|
||||
|
||||
mysql.logger.Debug("mysql", "forget complete for account", account)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
||||
defer cancel()
|
||||
_, err = mysql.db.ExecContext(ctx, `DELETE FROM forget where id = ?;`, id)
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) doForgetIteration(account string) (count int, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
||||
defer cancel()
|
||||
|
||||
rows, err := mysql.db.QueryContext(ctx, `
|
||||
SELECT account_messages.history_id
|
||||
FROM account_messages
|
||||
WHERE account_messages.account = ?
|
||||
LIMIT ?;`, account, cleanupRowLimit)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []uint64
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
err = rows.Scan(&id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows from account %s", len(ids), account))
|
||||
err = mysql.deleteHistoryIDs(ctx, ids)
|
||||
return len(ids), err
|
||||
}
|
||||
|
||||
func (mysql *MySQL) prepareStatements() (err error) {
|
||||
mysql.insertHistory, err = mysql.db.Prepare(`INSERT INTO history
|
||||
(data, msgid) VALUES (?, ?);`)
|
||||
@ -282,6 +468,11 @@ func (mysql *MySQL) prepareStatements() (err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mysql.insertAccountMessage, err = mysql.db.Prepare(`INSERT INTO account_messages
|
||||
(history_id, account) VALUES (?, ?);`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@ -290,6 +481,10 @@ func (mysql *MySQL) getTimeout() time.Duration {
|
||||
return time.Duration(atomic.LoadInt64(&mysql.timeout))
|
||||
}
|
||||
|
||||
func (mysql *MySQL) isTrackingAccountMessages() bool {
|
||||
return atomic.LoadUint32(&mysql.trackAccountMessages) != 0
|
||||
}
|
||||
|
||||
func (mysql *MySQL) logError(context string, err error) (quit bool) {
|
||||
if err != nil {
|
||||
mysql.logger.Error("mysql", context, err.Error())
|
||||
@ -298,7 +493,27 @@ func (mysql *MySQL) logError(context string, err error) (quit bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
func (mysql *MySQL) AddChannelItem(target string, item history.Item) (err error) {
|
||||
func (mysql *MySQL) Forget(account string) {
|
||||
if mysql.db == nil || account == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
|
||||
defer cancel()
|
||||
|
||||
_, err := mysql.db.ExecContext(ctx, `INSERT INTO forget (account) VALUES (?);`, account)
|
||||
if mysql.logError("can't insert into forget table", err) {
|
||||
return
|
||||
}
|
||||
|
||||
// wake up the forget goroutine if it's blocked:
|
||||
select {
|
||||
case mysql.wakeForgetter <- e{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (mysql *MySQL) AddChannelItem(target string, item history.Item, account string) (err error) {
|
||||
if mysql.db == nil {
|
||||
return
|
||||
}
|
||||
@ -316,6 +531,15 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item) (err error)
|
||||
}
|
||||
|
||||
err = mysql.insertSequenceEntry(ctx, target, item.Message.Time.UnixNano(), id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = mysql.insertAccountMessageEntry(ctx, id, account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@ -354,6 +578,15 @@ func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) insertAccountMessageEntry(ctx context.Context, id int64, account string) (err error) {
|
||||
if account == "" || !mysql.isTrackingAccountMessages() {
|
||||
return
|
||||
}
|
||||
_, err = mysql.insertAccountMessage.ExecContext(ctx, id, account)
|
||||
mysql.logError("could not insert account-message entry", err)
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item history.Item) (err error) {
|
||||
if mysql.db == nil {
|
||||
return
|
||||
@ -399,10 +632,102 @@ func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipient
|
||||
}
|
||||
}
|
||||
|
||||
err = mysql.insertAccountMessageEntry(ctx, id, senderAccount)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) msgidToTime(ctx context.Context, msgid string) (result time.Time, err error) {
|
||||
// note that accountName is the unfolded name
|
||||
func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
|
||||
if mysql.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
|
||||
defer cancel()
|
||||
|
||||
_, id, data, err := mysql.lookupMsgid(ctx, msgid, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if accountName != "*" {
|
||||
var item history.Item
|
||||
err = unmarshalItem(data, &item)
|
||||
// delete if the entry is corrupt
|
||||
if err == nil && item.AccountName != accountName {
|
||||
return ErrDisallowed
|
||||
}
|
||||
}
|
||||
|
||||
err = mysql.deleteHistoryIDs(ctx, []uint64{id})
|
||||
mysql.logError("couldn't delete msgid", err)
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) Export(account string, writer io.Writer) {
|
||||
if mysql.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
var lastSeen uint64
|
||||
for {
|
||||
rows := func() (count int) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
||||
defer cancel()
|
||||
|
||||
rows, rowsErr := mysql.db.QueryContext(ctx, `
|
||||
SELECT account_messages.history_id, history.data, sequence.target FROM account_messages
|
||||
INNER JOIN history ON history.id = account_messages.history_id
|
||||
INNER JOIN sequence ON account_messages.history_id = sequence.history_id
|
||||
WHERE account_messages.account = ? AND account_messages.history_id > ?
|
||||
LIMIT ?`, account, lastSeen, cleanupRowLimit)
|
||||
if rowsErr != nil {
|
||||
err = rowsErr
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var blob, jsonBlob []byte
|
||||
var target string
|
||||
var item history.Item
|
||||
err = rows.Scan(&id, &blob, &target)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = unmarshalItem(blob, &item)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
item.CfCorrespondent = target
|
||||
jsonBlob, err = json.Marshal(item)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
count++
|
||||
if lastSeen < id {
|
||||
lastSeen = id
|
||||
}
|
||||
writer.Write(jsonBlob)
|
||||
writer.Write([]byte{'\n'})
|
||||
}
|
||||
return
|
||||
}()
|
||||
if rows == 0 || err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mysql.logError("could not export history", err)
|
||||
return
|
||||
}
|
||||
|
||||
func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
|
||||
// in theory, we could optimize out a roundtrip to the database by using a subquery instead:
|
||||
// sequence.nanotime > (
|
||||
// SELECT sequence.nanotime FROM sequence, history
|
||||
@ -415,15 +740,27 @@ func (mysql *MySQL) msgidToTime(ctx context.Context, msgid string) (result time.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
row := mysql.db.QueryRowContext(ctx, `
|
||||
SELECT sequence.nanotime FROM sequence
|
||||
cols := `sequence.nanotime`
|
||||
if includeData {
|
||||
cols = `sequence.nanotime, sequence.history_id, history.data`
|
||||
}
|
||||
row := mysql.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM sequence
|
||||
INNER JOIN history ON history.id = sequence.history_id
|
||||
WHERE history.msgid = ? LIMIT 1;`, decoded)
|
||||
WHERE history.msgid = ? LIMIT 1;`, cols), decoded)
|
||||
var nanotime int64
|
||||
err = row.Scan(&nanotime)
|
||||
if mysql.logError("could not resolve msgid to time", err) {
|
||||
if !includeData {
|
||||
err = row.Scan(&nanotime)
|
||||
} else {
|
||||
err = row.Scan(&nanotime, &id, &data)
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
mysql.logError("could not resolve msgid to time", err)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
result = time.Unix(0, nanotime).UTC()
|
||||
return
|
||||
}
|
||||
@ -519,14 +856,14 @@ func (s *mySQLHistorySequence) Between(start, end history.Selector, limit int) (
|
||||
|
||||
startTime := start.Time
|
||||
if start.Msgid != "" {
|
||||
startTime, err = s.mysql.msgidToTime(ctx, start.Msgid)
|
||||
startTime, _, _, err = s.mysql.lookupMsgid(ctx, start.Msgid, false)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
endTime := end.Time
|
||||
if end.Msgid != "" {
|
||||
endTime, err = s.mysql.msgidToTime(ctx, end.Msgid)
|
||||
endTime, _, _, err = s.mysql.lookupMsgid(ctx, end.Msgid, false)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
|
||||
}
|
||||
|
||||
for _, channel := range client.Channels() {
|
||||
channel.AddHistoryItem(histItem)
|
||||
channel.AddHistoryItem(histItem, details.account)
|
||||
}
|
||||
|
||||
if target.Registered() {
|
||||
|
@ -91,7 +91,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
|
||||
Type: history.Privmsg,
|
||||
Message: splitMessage,
|
||||
Nick: source,
|
||||
})
|
||||
}, client.Account())
|
||||
} else {
|
||||
target, err := CasefoldName(targetString)
|
||||
user := server.clients.Get(target)
|
||||
|
@ -879,6 +879,76 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien
|
||||
return
|
||||
}
|
||||
|
||||
func (server *Server) ForgetHistory(accountName string) {
|
||||
// sanity check
|
||||
if accountName == "*" {
|
||||
return
|
||||
}
|
||||
|
||||
config := server.Config()
|
||||
if !config.History.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if cfAccount, err := CasefoldName(accountName); err == nil {
|
||||
server.historyDB.Forget(cfAccount)
|
||||
}
|
||||
|
||||
persistent := config.History.Persistent
|
||||
if persistent.Enabled && persistent.UnregisteredChannels && persistent.RegisteredChannels == PersistentMandatory && persistent.DirectMessages == PersistentMandatory {
|
||||
return
|
||||
}
|
||||
|
||||
predicate := func(item *history.Item) bool { return item.AccountName == accountName }
|
||||
|
||||
for _, channel := range server.channels.Channels() {
|
||||
channel.history.Delete(predicate)
|
||||
}
|
||||
|
||||
for _, client := range server.clients.AllClients() {
|
||||
client.history.Delete(predicate)
|
||||
}
|
||||
}
|
||||
|
||||
// deletes a message. target is a hint about what buffer it's in (not required for
|
||||
// persistent history, where all the msgids are indexed together). if accountName
|
||||
// is anything other than "*", it must match the recorded AccountName of the message
|
||||
func (server *Server) DeleteMessage(target, msgid, accountName string) (err error) {
|
||||
config := server.Config()
|
||||
var hist *history.Buffer
|
||||
|
||||
if target != "" {
|
||||
if target[0] == '#' {
|
||||
channel := server.channels.Get(target)
|
||||
if channel != nil {
|
||||
if status, _ := channel.historyStatus(config); status == HistoryEphemeral {
|
||||
hist = &channel.history
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client := server.clients.Get(target)
|
||||
if client != nil {
|
||||
if status, _ := client.historyStatus(config); status == HistoryEphemeral {
|
||||
hist = &client.history
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hist == nil {
|
||||
err = server.historyDB.DeleteMsgid(msgid, accountName)
|
||||
} else {
|
||||
count := hist.Delete(func(item *history.Item) bool {
|
||||
return item.Message.Msgid == msgid && (accountName == "*" || item.AccountName == accountName)
|
||||
})
|
||||
if count == 0 {
|
||||
err = errNoop
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// elistMatcher takes and matches ELIST conditions
|
||||
type elistMatcher struct {
|
||||
MinClientsActive bool
|
||||
|
@ -82,6 +82,13 @@ var OragonoServices = map[string]*ircService{
|
||||
Commands: hostservCommands,
|
||||
HelpBanner: hostservHelp,
|
||||
},
|
||||
"histserv": {
|
||||
Name: "HistServ",
|
||||
ShortName: "HISTSERV",
|
||||
CommandAliases: []string{"HISTSERV"},
|
||||
Commands: histservCommands,
|
||||
HelpBanner: histservHelp,
|
||||
},
|
||||
}
|
||||
|
||||
// all service commands at the protocol level, by uppercase command name
|
||||
|
18
oragono.yaml
18
oragono.yaml
@ -280,6 +280,10 @@ server:
|
||||
secure-nets:
|
||||
# - "10.0.0.0/8"
|
||||
|
||||
# oragono will write files to disk under certain circumstances, e.g.,
|
||||
# CPU profiling or data export. by default, these files will be written
|
||||
# to the working directory. set this to customize:
|
||||
# output-path: "/home/oragono/out"
|
||||
|
||||
# account options
|
||||
accounts:
|
||||
@ -577,6 +581,7 @@ oper-classes:
|
||||
- "samode"
|
||||
- "vhosts"
|
||||
- "chanreg"
|
||||
- "history"
|
||||
|
||||
# ircd operators
|
||||
opers:
|
||||
@ -772,7 +777,8 @@ roleplay:
|
||||
# add the real nickname, in parentheses, to the end of every roleplay message?
|
||||
add-suffix: true
|
||||
|
||||
# message history tracking, for the RESUME extension and possibly other uses in future
|
||||
# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
|
||||
# various autoreplay features, and the resume extension
|
||||
history:
|
||||
# should we store messages for later playback?
|
||||
# by default, messages are stored in RAM only; they do not persist
|
||||
@ -841,3 +847,13 @@ history:
|
||||
# if you enable this, strict nickname reservation is strongly recommended
|
||||
# as well.
|
||||
direct-messages: "opt-out"
|
||||
|
||||
# options to control how messages are stored and deleted:
|
||||
retention:
|
||||
# allow users to delete their own messages from history?
|
||||
allow-individual-delete: false
|
||||
|
||||
# if persistent history is enabled, create additional index tables,
|
||||
# allowing deletion of JSON export of an account's messages. this
|
||||
# may be needed for compliance with data privacy regulations.
|
||||
enable-account-indexing: false
|
||||
|
Loading…
Reference in New Issue
Block a user