2022-05-09 14:25:57 +00:00
|
|
|
package msgstore
|
2021-01-04 13:24:00 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
2021-11-03 17:18:04 +00:00
|
|
|
"context"
|
2021-01-04 13:24:00 +00:00
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2021-05-18 14:50:19 +00:00
|
|
|
"sort"
|
2021-01-04 13:24:00 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2021-03-31 09:59:13 +00:00
|
|
|
"git.sr.ht/~sircmpwn/go-bare"
|
2022-11-14 11:06:58 +00:00
|
|
|
"gopkg.in/irc.v4"
|
2022-05-09 10:34:43 +00:00
|
|
|
|
|
|
|
"git.sr.ht/~emersion/soju/database"
|
2022-05-09 14:15:00 +00:00
|
|
|
"git.sr.ht/~emersion/soju/xirc"
|
2021-01-04 13:24:00 +00:00
|
|
|
)
|
|
|
|
|
2021-10-06 09:41:39 +00:00
|
|
|
const (
|
|
|
|
fsMessageStoreMaxFiles = 20
|
|
|
|
fsMessageStoreMaxTries = 100
|
|
|
|
)
|
2021-01-04 13:24:00 +00:00
|
|
|
|
2021-09-17 21:29:33 +00:00
|
|
|
func escapeFilename(unsafe string) (safe string) {
|
|
|
|
if unsafe == "." {
|
|
|
|
return "-"
|
|
|
|
} else if unsafe == ".." {
|
|
|
|
return "--"
|
|
|
|
} else {
|
|
|
|
return strings.NewReplacer("/", "-", "\\", "-").Replace(unsafe)
|
|
|
|
}
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
|
2021-03-31 09:59:13 +00:00
|
|
|
type date struct {
|
|
|
|
Year, Month, Day int
|
|
|
|
}
|
|
|
|
|
|
|
|
func newDate(t time.Time) date {
|
|
|
|
year, month, day := t.Date()
|
|
|
|
return date{year, int(month), day}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d date) Time() time.Time {
|
|
|
|
return time.Date(d.Year, time.Month(d.Month), d.Day, 0, 0, 0, 0, time.Local)
|
|
|
|
}
|
|
|
|
|
|
|
|
type fsMsgID struct {
|
|
|
|
Date date
|
|
|
|
Offset bare.Int
|
|
|
|
}
|
|
|
|
|
|
|
|
func (fsMsgID) msgIDType() msgIDType {
|
|
|
|
return msgIDFS
|
|
|
|
}
|
|
|
|
|
|
|
|
func parseFSMsgID(s string) (netID int64, entity string, t time.Time, offset int64, err error) {
|
|
|
|
var id fsMsgID
|
2022-05-09 14:25:57 +00:00
|
|
|
netID, entity, err = ParseMsgID(s, &id)
|
2021-03-31 09:59:13 +00:00
|
|
|
if err != nil {
|
|
|
|
return 0, "", time.Time{}, 0, err
|
|
|
|
}
|
|
|
|
return netID, entity, id.Date.Time(), int64(id.Offset), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func formatFSMsgID(netID int64, entity string, t time.Time, offset int64) string {
|
|
|
|
id := fsMsgID{
|
|
|
|
Date: newDate(t),
|
|
|
|
Offset: bare.Int(offset),
|
|
|
|
}
|
|
|
|
return formatMsgID(netID, entity, &id)
|
|
|
|
}
|
|
|
|
|
2021-10-06 09:41:39 +00:00
|
|
|
type fsMessageStoreFile struct {
|
|
|
|
*os.File
|
|
|
|
lastUse time.Time
|
|
|
|
}
|
|
|
|
|
2021-01-04 13:24:00 +00:00
|
|
|
// fsMessageStore is a per-user on-disk store for IRC messages.
|
2021-10-14 18:51:03 +00:00
|
|
|
//
|
|
|
|
// It mimicks the ZNC log layout and format. See the ZNC source:
|
|
|
|
// https://github.com/znc/znc/blob/master/modules/log.cpp
|
2021-01-04 13:24:00 +00:00
|
|
|
type fsMessageStore struct {
|
|
|
|
root string
|
2022-05-09 10:34:43 +00:00
|
|
|
user *database.User
|
2021-01-04 13:24:00 +00:00
|
|
|
|
2021-10-06 09:41:39 +00:00
|
|
|
// Write-only files used by Append
|
|
|
|
files map[string]*fsMessageStoreFile // indexed by entity
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
var (
|
|
|
|
_ Store = (*fsMessageStore)(nil)
|
|
|
|
_ ChatHistoryStore = (*fsMessageStore)(nil)
|
|
|
|
_ SearchStore = (*fsMessageStore)(nil)
|
|
|
|
_ RenameNetworkStore = (*fsMessageStore)(nil)
|
|
|
|
)
|
2021-05-18 12:19:34 +00:00
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func NewFSStore(root string, user *database.User) *fsMessageStore {
|
2021-01-04 13:24:00 +00:00
|
|
|
return &fsMessageStore{
|
2022-02-25 20:05:10 +00:00
|
|
|
root: filepath.Join(root, escapeFilename(user.Username)),
|
|
|
|
user: user,
|
2021-10-06 09:41:39 +00:00
|
|
|
files: make(map[string]*fsMessageStoreFile),
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-09 10:34:43 +00:00
|
|
|
func (ms *fsMessageStore) logPath(network *database.Network, entity string, t time.Time) string {
|
2021-01-04 13:24:00 +00:00
|
|
|
year, month, day := t.Date()
|
|
|
|
filename := fmt.Sprintf("%04d-%02d-%02d.log", year, month, day)
|
2021-09-17 21:29:33 +00:00
|
|
|
return filepath.Join(ms.root, escapeFilename(network.GetName()), escapeFilename(entity), filename)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// nextMsgID queries the message ID for the next message to be written to f.
|
2022-05-09 10:34:43 +00:00
|
|
|
func nextFSMsgID(network *database.Network, entity string, t time.Time, f *os.File) (string, error) {
|
2021-01-04 13:24:00 +00:00
|
|
|
offset, err := f.Seek(0, io.SeekEnd)
|
|
|
|
if err != nil {
|
2021-05-11 10:42:12 +00:00
|
|
|
return "", fmt.Errorf("failed to query next FS message ID: %v", err)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
2021-01-04 15:26:30 +00:00
|
|
|
return formatFSMsgID(network.ID, entity, t, offset), nil
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 10:34:43 +00:00
|
|
|
func (ms *fsMessageStore) LastMsgID(network *database.Network, entity string, t time.Time) (string, error) {
|
2021-01-04 13:24:00 +00:00
|
|
|
p := ms.logPath(network, entity, t)
|
|
|
|
fi, err := os.Stat(p)
|
|
|
|
if os.IsNotExist(err) {
|
2021-01-04 15:26:30 +00:00
|
|
|
return formatFSMsgID(network.ID, entity, t, -1), nil
|
2021-01-04 13:24:00 +00:00
|
|
|
} else if err != nil {
|
2021-05-11 10:42:12 +00:00
|
|
|
return "", fmt.Errorf("failed to query last FS message ID: %v", err)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
2021-01-04 15:26:30 +00:00
|
|
|
return formatFSMsgID(network.ID, entity, t, fi.Size()-1), nil
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 10:34:43 +00:00
|
|
|
func (ms *fsMessageStore) Append(network *database.Network, entity string, msg *irc.Message) (string, error) {
|
2021-01-04 13:24:00 +00:00
|
|
|
s := formatMessage(msg)
|
|
|
|
if s == "" {
|
|
|
|
return "", nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var t time.Time
|
|
|
|
if tag, ok := msg.Tags["time"]; ok {
|
|
|
|
var err error
|
2022-05-09 14:15:00 +00:00
|
|
|
t, err = time.Parse(xirc.ServerTimeLayout, string(tag))
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to parse message time tag: %v", err)
|
|
|
|
}
|
|
|
|
t = t.In(time.Local)
|
|
|
|
} else {
|
|
|
|
t = time.Now()
|
|
|
|
}
|
|
|
|
|
|
|
|
f := ms.files[entity]
|
|
|
|
|
|
|
|
// TODO: handle non-monotonic clock behaviour
|
|
|
|
path := ms.logPath(network, entity, t)
|
|
|
|
if f == nil || f.Name() != path {
|
|
|
|
dir := filepath.Dir(path)
|
2021-06-14 15:57:13 +00:00
|
|
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
2021-01-04 13:24:00 +00:00
|
|
|
return "", fmt.Errorf("failed to create message logs directory %q: %v", dir, err)
|
|
|
|
}
|
|
|
|
|
2021-10-06 09:41:39 +00:00
|
|
|
ff, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0640)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to open message log file %q: %v", path, err)
|
|
|
|
}
|
|
|
|
|
2021-10-06 09:41:39 +00:00
|
|
|
if f != nil {
|
|
|
|
f.Close()
|
|
|
|
}
|
|
|
|
f = &fsMessageStoreFile{File: ff}
|
2021-01-04 13:24:00 +00:00
|
|
|
ms.files[entity] = f
|
|
|
|
}
|
|
|
|
|
2021-10-06 09:41:39 +00:00
|
|
|
f.lastUse = time.Now()
|
|
|
|
|
|
|
|
if len(ms.files) > fsMessageStoreMaxFiles {
|
|
|
|
entities := make([]string, 0, len(ms.files))
|
|
|
|
for name := range ms.files {
|
|
|
|
entities = append(entities, name)
|
|
|
|
}
|
|
|
|
sort.Slice(entities, func(i, j int) bool {
|
|
|
|
a, b := entities[i], entities[j]
|
|
|
|
return ms.files[a].lastUse.Before(ms.files[b].lastUse)
|
|
|
|
})
|
|
|
|
entities = entities[0 : len(entities)-fsMessageStoreMaxFiles]
|
|
|
|
for _, name := range entities {
|
|
|
|
ms.files[name].Close()
|
|
|
|
delete(ms.files, name)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
msgID, err := nextFSMsgID(network, entity, t, f.File)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to generate message ID: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = fmt.Fprintf(f, "[%02d:%02d:%02d] %s\n", t.Hour(), t.Minute(), t.Second(), s)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to log message to %q: %v", f.Name(), err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return msgID, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (ms *fsMessageStore) Close() error {
|
|
|
|
var closeErr error
|
|
|
|
for _, f := range ms.files {
|
|
|
|
if err := f.Close(); err != nil {
|
|
|
|
closeErr = fmt.Errorf("failed to close message store: %v", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return closeErr
|
|
|
|
}
|
|
|
|
|
|
|
|
// formatMessage formats a message log line. It assumes a well-formed IRC
|
|
|
|
// message.
|
|
|
|
func formatMessage(msg *irc.Message) string {
|
|
|
|
switch strings.ToUpper(msg.Command) {
|
|
|
|
case "NICK":
|
|
|
|
return fmt.Sprintf("*** %s is now known as %s", msg.Prefix.Name, msg.Params[0])
|
|
|
|
case "JOIN":
|
|
|
|
return fmt.Sprintf("*** Joins: %s (%s@%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host)
|
|
|
|
case "PART":
|
|
|
|
var reason string
|
|
|
|
if len(msg.Params) > 1 {
|
|
|
|
reason = msg.Params[1]
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("*** Parts: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason)
|
|
|
|
case "KICK":
|
|
|
|
nick := msg.Params[1]
|
|
|
|
var reason string
|
|
|
|
if len(msg.Params) > 2 {
|
|
|
|
reason = msg.Params[2]
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("*** %s was kicked by %s (%s)", nick, msg.Prefix.Name, reason)
|
|
|
|
case "QUIT":
|
|
|
|
var reason string
|
|
|
|
if len(msg.Params) > 0 {
|
|
|
|
reason = msg.Params[0]
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("*** Quits: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason)
|
|
|
|
case "TOPIC":
|
|
|
|
var topic string
|
|
|
|
if len(msg.Params) > 1 {
|
|
|
|
topic = msg.Params[1]
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("*** %s changes topic to '%s'", msg.Prefix.Name, topic)
|
|
|
|
case "MODE":
|
|
|
|
return fmt.Sprintf("*** %s sets mode: %s", msg.Prefix.Name, strings.Join(msg.Params[1:], " "))
|
|
|
|
case "NOTICE":
|
|
|
|
return fmt.Sprintf("-%s- %s", msg.Prefix.Name, msg.Params[1])
|
|
|
|
case "PRIVMSG":
|
2022-05-09 14:15:00 +00:00
|
|
|
if cmd, params, ok := xirc.ParseCTCPMessage(msg); ok && cmd == "ACTION" {
|
2021-01-04 13:24:00 +00:00
|
|
|
return fmt.Sprintf("* %s %s", msg.Prefix.Name, params)
|
|
|
|
} else {
|
|
|
|
return fmt.Sprintf("<%s> %s", msg.Prefix.Name, msg.Params[1])
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-09 10:34:43 +00:00
|
|
|
func (ms *fsMessageStore) parseMessage(line string, network *database.Network, entity string, ref time.Time, events bool) (*irc.Message, time.Time, error) {
|
2021-01-04 13:24:00 +00:00
|
|
|
var hour, minute, second int
|
|
|
|
_, err := fmt.Sscanf(line, "[%02d:%02d:%02d] ", &hour, &minute, &second)
|
|
|
|
if err != nil {
|
2021-05-11 10:42:12 +00:00
|
|
|
return nil, time.Time{}, fmt.Errorf("malformed timestamp prefix: %v", err)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
line = line[11:]
|
|
|
|
|
2021-10-08 22:13:16 +00:00
|
|
|
var cmd string
|
|
|
|
var prefix *irc.Prefix
|
|
|
|
var params []string
|
|
|
|
if events && strings.HasPrefix(line, "*** ") {
|
|
|
|
parts := strings.SplitN(line[4:], " ", 2)
|
2021-01-04 13:24:00 +00:00
|
|
|
if len(parts) != 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
2021-10-08 22:13:16 +00:00
|
|
|
switch parts[0] {
|
|
|
|
case "Joins:", "Parts:", "Quits:":
|
|
|
|
args := strings.SplitN(parts[1], " ", 3)
|
|
|
|
if len(args) < 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
|
|
|
nick := args[0]
|
|
|
|
mask := strings.TrimSuffix(strings.TrimPrefix(args[1], "("), ")")
|
|
|
|
maskParts := strings.SplitN(mask, "@", 2)
|
|
|
|
if len(maskParts) != 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
|
|
|
prefix = &irc.Prefix{
|
|
|
|
Name: nick,
|
|
|
|
User: maskParts[0],
|
|
|
|
Host: maskParts[1],
|
|
|
|
}
|
|
|
|
var reason string
|
|
|
|
if len(args) > 2 {
|
|
|
|
reason = strings.TrimSuffix(strings.TrimPrefix(args[2], "("), ")")
|
|
|
|
}
|
|
|
|
switch parts[0] {
|
|
|
|
case "Joins:":
|
|
|
|
cmd = "JOIN"
|
|
|
|
params = []string{entity}
|
|
|
|
case "Parts:":
|
|
|
|
cmd = "PART"
|
|
|
|
if reason != "" {
|
|
|
|
params = []string{entity, reason}
|
|
|
|
} else {
|
|
|
|
params = []string{entity}
|
|
|
|
}
|
|
|
|
case "Quits:":
|
|
|
|
cmd = "QUIT"
|
|
|
|
if reason != "" {
|
|
|
|
params = []string{reason}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
nick := parts[0]
|
|
|
|
rem := parts[1]
|
|
|
|
if r := strings.TrimPrefix(rem, "is now known as "); r != rem {
|
|
|
|
cmd = "NICK"
|
|
|
|
prefix = &irc.Prefix{
|
|
|
|
Name: nick,
|
|
|
|
}
|
|
|
|
params = []string{r}
|
|
|
|
} else if r := strings.TrimPrefix(rem, "was kicked by "); r != rem {
|
|
|
|
args := strings.SplitN(r, " ", 2)
|
|
|
|
if len(args) != 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
|
|
|
cmd = "KICK"
|
|
|
|
prefix = &irc.Prefix{
|
|
|
|
Name: args[0],
|
|
|
|
}
|
|
|
|
reason := strings.TrimSuffix(strings.TrimPrefix(args[1], "("), ")")
|
|
|
|
params = []string{entity, nick}
|
|
|
|
if reason != "" {
|
|
|
|
params = append(params, reason)
|
|
|
|
}
|
|
|
|
} else if r := strings.TrimPrefix(rem, "changes topic to "); r != rem {
|
|
|
|
cmd = "TOPIC"
|
|
|
|
prefix = &irc.Prefix{
|
|
|
|
Name: nick,
|
|
|
|
}
|
|
|
|
topic := strings.TrimSuffix(strings.TrimPrefix(r, "'"), "'")
|
|
|
|
params = []string{entity, topic}
|
|
|
|
} else if r := strings.TrimPrefix(rem, "sets mode: "); r != rem {
|
|
|
|
cmd = "MODE"
|
|
|
|
prefix = &irc.Prefix{
|
|
|
|
Name: nick,
|
|
|
|
}
|
|
|
|
params = append([]string{entity}, strings.Split(r, " ")...)
|
|
|
|
} else {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
2021-10-08 22:13:16 +00:00
|
|
|
} else {
|
|
|
|
var sender, text string
|
|
|
|
if strings.HasPrefix(line, "<") {
|
|
|
|
cmd = "PRIVMSG"
|
|
|
|
parts := strings.SplitN(line[1:], "> ", 2)
|
|
|
|
if len(parts) != 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
|
|
|
sender, text = parts[0], parts[1]
|
|
|
|
} else if strings.HasPrefix(line, "-") {
|
|
|
|
cmd = "NOTICE"
|
|
|
|
parts := strings.SplitN(line[1:], "- ", 2)
|
|
|
|
if len(parts) != 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
|
|
|
sender, text = parts[0], parts[1]
|
|
|
|
} else if strings.HasPrefix(line, "* ") {
|
|
|
|
cmd = "PRIVMSG"
|
|
|
|
parts := strings.SplitN(line[2:], " ", 2)
|
|
|
|
if len(parts) != 2 {
|
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
|
|
|
sender, text = parts[0], "\x01ACTION "+parts[1]+"\x01"
|
|
|
|
} else {
|
2021-01-04 13:24:00 +00:00
|
|
|
return nil, time.Time{}, nil
|
|
|
|
}
|
2021-10-08 22:13:16 +00:00
|
|
|
|
|
|
|
prefix = &irc.Prefix{Name: sender}
|
2022-02-25 20:05:10 +00:00
|
|
|
if entity == sender {
|
|
|
|
// This is a direct message from a user to us. We don't store own
|
|
|
|
// our nickname in the logs, so grab it from the network settings.
|
|
|
|
// Not very accurate since this may not match our nick at the time
|
|
|
|
// the message was received, but we can't do a lot better.
|
2022-05-09 10:34:43 +00:00
|
|
|
entity = database.GetNick(ms.user, network)
|
2022-02-25 20:05:10 +00:00
|
|
|
}
|
2021-10-08 22:13:16 +00:00
|
|
|
params = []string{entity, text}
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
year, month, day := ref.Date()
|
|
|
|
t := time.Date(year, month, day, hour, minute, second, 0, time.Local)
|
|
|
|
|
|
|
|
msg := &irc.Message{
|
2022-11-14 11:06:58 +00:00
|
|
|
Tags: map[string]string{
|
|
|
|
"time": xirc.FormatServerTime(t),
|
2021-01-04 13:24:00 +00:00
|
|
|
},
|
2021-10-08 22:13:16 +00:00
|
|
|
Prefix: prefix,
|
2021-01-04 13:24:00 +00:00
|
|
|
Command: cmd,
|
2021-10-08 22:13:16 +00:00
|
|
|
Params: params,
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
return msg, t, nil
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) parseMessagesBefore(ref time.Time, end time.Time, options *LoadMessageOptions, afterOffset int64, selector func(m *irc.Message) bool) ([]*irc.Message, error) {
|
2022-05-09 13:36:39 +00:00
|
|
|
path := ms.logPath(options.Network, options.Entity, ref)
|
2021-01-04 13:24:00 +00:00
|
|
|
f, err := os.Open(path)
|
|
|
|
if err != nil {
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
return nil, nil
|
|
|
|
}
|
2021-05-11 10:42:12 +00:00
|
|
|
return nil, fmt.Errorf("failed to parse messages before ref: %v", err)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
|
2022-05-09 13:36:39 +00:00
|
|
|
historyRing := make([]*irc.Message, options.Limit)
|
2021-01-04 13:24:00 +00:00
|
|
|
cur := 0
|
|
|
|
|
|
|
|
sc := bufio.NewScanner(f)
|
|
|
|
|
|
|
|
if afterOffset >= 0 {
|
|
|
|
if _, err := f.Seek(afterOffset, io.SeekStart); err != nil {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
sc.Scan() // skip till next newline
|
|
|
|
}
|
|
|
|
|
|
|
|
for sc.Scan() {
|
2022-05-09 13:36:39 +00:00
|
|
|
msg, t, err := ms.parseMessage(sc.Text(), options.Network, options.Entity, ref, options.Events)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2021-03-27 12:08:31 +00:00
|
|
|
} else if msg == nil || !t.After(end) {
|
2021-01-04 13:24:00 +00:00
|
|
|
continue
|
|
|
|
} else if !t.Before(ref) {
|
|
|
|
break
|
|
|
|
}
|
2022-02-21 18:44:56 +00:00
|
|
|
if selector != nil && !selector(msg) {
|
|
|
|
continue
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
|
2022-05-09 13:36:39 +00:00
|
|
|
historyRing[cur%options.Limit] = msg
|
2021-01-04 13:24:00 +00:00
|
|
|
cur++
|
|
|
|
}
|
|
|
|
if sc.Err() != nil {
|
2021-05-11 10:42:12 +00:00
|
|
|
return nil, fmt.Errorf("failed to parse messages before ref: scanner error: %v", sc.Err())
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 13:36:39 +00:00
|
|
|
n := options.Limit
|
|
|
|
if cur < options.Limit {
|
2021-01-04 13:24:00 +00:00
|
|
|
n = cur
|
|
|
|
}
|
2022-05-09 13:36:39 +00:00
|
|
|
start := (cur - n + options.Limit) % options.Limit
|
2021-01-04 13:24:00 +00:00
|
|
|
|
2022-05-09 13:36:39 +00:00
|
|
|
if start+n <= options.Limit { // ring doesnt wrap
|
2021-01-04 13:24:00 +00:00
|
|
|
return historyRing[start : start+n], nil
|
|
|
|
} else { // ring wraps
|
|
|
|
history := make([]*irc.Message, n)
|
|
|
|
r := copy(history, historyRing[start:])
|
|
|
|
copy(history[r:], historyRing[:n-r])
|
|
|
|
return history, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) parseMessagesAfter(ref time.Time, end time.Time, options *LoadMessageOptions, selector func(m *irc.Message) bool) ([]*irc.Message, error) {
|
2022-05-09 13:36:39 +00:00
|
|
|
path := ms.logPath(options.Network, options.Entity, ref)
|
2021-01-04 13:24:00 +00:00
|
|
|
f, err := os.Open(path)
|
|
|
|
if err != nil {
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
return nil, nil
|
|
|
|
}
|
2021-05-11 10:42:12 +00:00
|
|
|
return nil, fmt.Errorf("failed to parse messages after ref: %v", err)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
var history []*irc.Message
|
|
|
|
sc := bufio.NewScanner(f)
|
2022-05-09 13:36:39 +00:00
|
|
|
for sc.Scan() && len(history) < options.Limit {
|
|
|
|
msg, t, err := ms.parseMessage(sc.Text(), options.Network, options.Entity, ref, options.Events)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
} else if msg == nil || !t.After(ref) {
|
|
|
|
continue
|
2021-03-27 12:08:31 +00:00
|
|
|
} else if !t.Before(end) {
|
|
|
|
break
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
2022-02-21 18:44:56 +00:00
|
|
|
if selector != nil && !selector(msg) {
|
|
|
|
continue
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
|
|
|
|
history = append(history, msg)
|
|
|
|
}
|
|
|
|
if sc.Err() != nil {
|
2021-05-11 10:42:12 +00:00
|
|
|
return nil, fmt.Errorf("failed to parse messages after ref: scanner error: %v", sc.Err())
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return history, nil
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) getBeforeTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions, selector func(m *irc.Message) bool) ([]*irc.Message, error) {
|
2022-02-21 18:44:56 +00:00
|
|
|
if start.IsZero() {
|
|
|
|
start = time.Now()
|
|
|
|
} else {
|
|
|
|
start = start.In(time.Local)
|
|
|
|
}
|
chathistory: Fix truncated backlog due to timezones
Because msgstore_fs writes logs in localtime, the CHATHISTORY timestamps
(UTC) must be converted to localtime prior to filtering ranges ensure
the right range is sent back to the client.
Prior to this patch, the iteration back from the BEFORE time failed to
load the hours between midnight UTC and midnight localtime in each day's
logged messages. This is because the final time to be considered in a
day's log file (the "start" time) reuses the previous start time's
locale:
start = time.Date(year, month, day, 0, 0, 0, 0, start.Location()).Add(-1)
By converting the original start and end from the CHATHISTORY commands
to localtime in Load*Time and ListTargets, we ensure we read through
midnight each day.
2021-10-07 03:39:07 +00:00
|
|
|
end = end.In(time.Local)
|
2022-05-09 13:36:39 +00:00
|
|
|
messages := make([]*irc.Message, options.Limit)
|
|
|
|
remaining := options.Limit
|
2021-01-04 13:24:00 +00:00
|
|
|
tries := 0
|
2021-03-27 12:08:31 +00:00
|
|
|
for remaining > 0 && tries < fsMessageStoreMaxTries && end.Before(start) {
|
2022-05-09 13:36:39 +00:00
|
|
|
parseOptions := *options
|
|
|
|
parseOptions.Limit = remaining
|
|
|
|
buf, err := ms.parseMessagesBefore(start, end, &parseOptions, -1, selector)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if len(buf) == 0 {
|
|
|
|
tries++
|
|
|
|
} else {
|
|
|
|
tries = 0
|
|
|
|
}
|
2022-02-21 18:44:56 +00:00
|
|
|
copy(messages[remaining-len(buf):], buf)
|
2021-01-04 13:24:00 +00:00
|
|
|
remaining -= len(buf)
|
2021-03-27 12:08:31 +00:00
|
|
|
year, month, day := start.Date()
|
|
|
|
start = time.Date(year, month, day, 0, 0, 0, 0, start.Location()).Add(-1)
|
2021-11-03 17:21:12 +00:00
|
|
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-02-21 18:44:56 +00:00
|
|
|
return messages[remaining:], nil
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) LoadBeforeTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) {
|
2022-05-09 13:36:39 +00:00
|
|
|
return ms.getBeforeTime(ctx, start, end, options, nil)
|
2022-02-21 18:44:56 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) getAfterTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions, selector func(m *irc.Message) bool) ([]*irc.Message, error) {
|
chathistory: Fix truncated backlog due to timezones
Because msgstore_fs writes logs in localtime, the CHATHISTORY timestamps
(UTC) must be converted to localtime prior to filtering ranges ensure
the right range is sent back to the client.
Prior to this patch, the iteration back from the BEFORE time failed to
load the hours between midnight UTC and midnight localtime in each day's
logged messages. This is because the final time to be considered in a
day's log file (the "start" time) reuses the previous start time's
locale:
start = time.Date(year, month, day, 0, 0, 0, 0, start.Location()).Add(-1)
By converting the original start and end from the CHATHISTORY commands
to localtime in Load*Time and ListTargets, we ensure we read through
midnight each day.
2021-10-07 03:39:07 +00:00
|
|
|
start = start.In(time.Local)
|
2022-02-21 18:44:56 +00:00
|
|
|
if end.IsZero() {
|
|
|
|
end = time.Now()
|
|
|
|
} else {
|
|
|
|
end = end.In(time.Local)
|
|
|
|
}
|
|
|
|
var messages []*irc.Message
|
2022-05-09 13:36:39 +00:00
|
|
|
remaining := options.Limit
|
2021-01-04 13:24:00 +00:00
|
|
|
tries := 0
|
2021-03-27 12:08:31 +00:00
|
|
|
for remaining > 0 && tries < fsMessageStoreMaxTries && start.Before(end) {
|
2022-05-09 13:36:39 +00:00
|
|
|
parseOptions := *options
|
|
|
|
parseOptions.Limit = remaining
|
|
|
|
buf, err := ms.parseMessagesAfter(start, end, &parseOptions, selector)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if len(buf) == 0 {
|
|
|
|
tries++
|
|
|
|
} else {
|
|
|
|
tries = 0
|
|
|
|
}
|
2022-02-21 18:44:56 +00:00
|
|
|
messages = append(messages, buf...)
|
2021-01-04 13:24:00 +00:00
|
|
|
remaining -= len(buf)
|
2021-03-27 12:08:31 +00:00
|
|
|
year, month, day := start.Date()
|
|
|
|
start = time.Date(year, month, day+1, 0, 0, 0, 0, start.Location())
|
2021-11-03 17:21:12 +00:00
|
|
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
2022-02-21 18:44:56 +00:00
|
|
|
return messages, nil
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) LoadAfterTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) {
|
2022-05-09 13:36:39 +00:00
|
|
|
return ms.getAfterTime(ctx, start, end, options, nil)
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) LoadLatestID(ctx context.Context, id string, options *LoadMessageOptions) ([]*irc.Message, error) {
|
2021-01-04 13:24:00 +00:00
|
|
|
var afterTime time.Time
|
|
|
|
var afterOffset int64
|
|
|
|
if id != "" {
|
2021-01-04 15:26:30 +00:00
|
|
|
var idNet int64
|
|
|
|
var idEntity string
|
2021-01-04 13:24:00 +00:00
|
|
|
var err error
|
2021-01-04 15:26:30 +00:00
|
|
|
idNet, idEntity, afterTime, afterOffset, err = parseFSMsgID(id)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2022-05-09 13:36:39 +00:00
|
|
|
if idNet != options.Network.ID || idEntity != options.Entity {
|
2021-01-04 13:24:00 +00:00
|
|
|
return nil, fmt.Errorf("cannot find message ID: message ID doesn't match network/entity")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-09 13:36:39 +00:00
|
|
|
history := make([]*irc.Message, options.Limit)
|
2021-01-04 13:24:00 +00:00
|
|
|
t := time.Now()
|
2022-05-09 13:36:39 +00:00
|
|
|
remaining := options.Limit
|
2021-01-04 13:24:00 +00:00
|
|
|
tries := 0
|
|
|
|
for remaining > 0 && tries < fsMessageStoreMaxTries && !truncateDay(t).Before(afterTime) {
|
|
|
|
var offset int64 = -1
|
|
|
|
if afterOffset >= 0 && truncateDay(t).Equal(afterTime) {
|
|
|
|
offset = afterOffset
|
|
|
|
}
|
|
|
|
|
2022-05-09 13:36:39 +00:00
|
|
|
parseOptions := *options
|
|
|
|
parseOptions.Limit = remaining
|
|
|
|
buf, err := ms.parseMessagesBefore(t, time.Time{}, &parseOptions, offset, nil)
|
2021-01-04 13:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if len(buf) == 0 {
|
|
|
|
tries++
|
|
|
|
} else {
|
|
|
|
tries = 0
|
|
|
|
}
|
|
|
|
copy(history[remaining-len(buf):], buf)
|
|
|
|
remaining -= len(buf)
|
|
|
|
year, month, day := t.Date()
|
|
|
|
t = time.Date(year, month, day, 0, 0, 0, 0, t.Location()).Add(-1)
|
2021-11-03 17:21:12 +00:00
|
|
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2021-01-04 13:24:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return history[remaining:], nil
|
|
|
|
}
|
2021-05-18 14:50:19 +00:00
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) ListTargets(ctx context.Context, network *database.Network, start, end time.Time, limit int, events bool) ([]ChatHistoryTarget, error) {
|
chathistory: Fix truncated backlog due to timezones
Because msgstore_fs writes logs in localtime, the CHATHISTORY timestamps
(UTC) must be converted to localtime prior to filtering ranges ensure
the right range is sent back to the client.
Prior to this patch, the iteration back from the BEFORE time failed to
load the hours between midnight UTC and midnight localtime in each day's
logged messages. This is because the final time to be considered in a
day's log file (the "start" time) reuses the previous start time's
locale:
start = time.Date(year, month, day, 0, 0, 0, 0, start.Location()).Add(-1)
By converting the original start and end from the CHATHISTORY commands
to localtime in Load*Time and ListTargets, we ensure we read through
midnight each day.
2021-10-07 03:39:07 +00:00
|
|
|
start = start.In(time.Local)
|
|
|
|
end = end.In(time.Local)
|
2021-09-17 21:29:33 +00:00
|
|
|
rootPath := filepath.Join(ms.root, escapeFilename(network.GetName()))
|
2021-05-18 14:50:19 +00:00
|
|
|
root, err := os.Open(rootPath)
|
2021-10-12 15:34:22 +00:00
|
|
|
if os.IsNotExist(err) {
|
|
|
|
return nil, nil
|
|
|
|
} else if err != nil {
|
2021-05-18 14:50:19 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// The returned targets are escaped, and there is no way to un-escape
|
|
|
|
// TODO: switch to ReadDir (Go 1.16+)
|
|
|
|
targetNames, err := root.Readdirnames(0)
|
|
|
|
root.Close()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
var targets []ChatHistoryTarget
|
2021-05-18 14:50:19 +00:00
|
|
|
for _, target := range targetNames {
|
|
|
|
// target is already escaped here
|
|
|
|
targetPath := filepath.Join(rootPath, target)
|
|
|
|
targetDir, err := os.Open(targetPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
entries, err := targetDir.Readdir(0)
|
|
|
|
targetDir.Close()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// We use mtime here, which may give imprecise or incorrect results
|
|
|
|
var t time.Time
|
|
|
|
for _, entry := range entries {
|
|
|
|
if entry.ModTime().After(t) {
|
|
|
|
t = entry.ModTime()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// The timestamps we get from logs have second granularity
|
|
|
|
t = truncateSecond(t)
|
|
|
|
|
|
|
|
// Filter out targets that don't fullfil the time bounds
|
|
|
|
if !isTimeBetween(t, start, end) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
targets = append(targets, ChatHistoryTarget{
|
2021-05-18 14:50:19 +00:00
|
|
|
Name: target,
|
|
|
|
LatestMessage: t,
|
|
|
|
})
|
2021-11-03 17:21:12 +00:00
|
|
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2021-05-18 14:50:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Sort targets by latest message time, backwards or forwards depending on
|
|
|
|
// the order of the time bounds
|
|
|
|
sort.Slice(targets, func(i, j int) bool {
|
|
|
|
t1, t2 := targets[i].LatestMessage, targets[j].LatestMessage
|
|
|
|
if start.Before(end) {
|
|
|
|
return t1.Before(t2)
|
|
|
|
} else {
|
|
|
|
return !t1.Before(t2)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Truncate the result if necessary
|
|
|
|
if len(targets) > limit {
|
|
|
|
targets = targets[:limit]
|
|
|
|
}
|
|
|
|
|
|
|
|
return targets, nil
|
|
|
|
}
|
|
|
|
|
2022-05-09 14:25:57 +00:00
|
|
|
func (ms *fsMessageStore) Search(ctx context.Context, network *database.Network, opts *SearchMessageOptions) ([]*irc.Message, error) {
|
2022-05-09 13:44:41 +00:00
|
|
|
text := strings.ToLower(opts.Text)
|
2022-02-21 18:44:56 +00:00
|
|
|
selector := func(m *irc.Message) bool {
|
2022-05-09 13:44:41 +00:00
|
|
|
if opts.From != "" && m.User != opts.From {
|
2022-02-21 18:44:56 +00:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
if text != "" && !strings.Contains(strings.ToLower(m.Params[1]), text) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
2022-05-09 14:25:57 +00:00
|
|
|
loadOptions := LoadMessageOptions{
|
2022-05-09 13:36:39 +00:00
|
|
|
Network: network,
|
2022-05-09 13:44:41 +00:00
|
|
|
Entity: opts.In,
|
|
|
|
Limit: opts.Limit,
|
2022-05-09 13:36:39 +00:00
|
|
|
}
|
2022-05-09 13:44:41 +00:00
|
|
|
if !opts.Start.IsZero() {
|
|
|
|
return ms.getAfterTime(ctx, opts.Start, opts.End, &loadOptions, selector)
|
2022-02-21 18:44:56 +00:00
|
|
|
} else {
|
2022-05-09 13:44:41 +00:00
|
|
|
return ms.getBeforeTime(ctx, opts.End, opts.Start, &loadOptions, selector)
|
2022-02-21 18:44:56 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-09 10:34:43 +00:00
|
|
|
func (ms *fsMessageStore) RenameNetwork(oldNet, newNet *database.Network) error {
|
2021-10-15 16:11:04 +00:00
|
|
|
oldDir := filepath.Join(ms.root, escapeFilename(oldNet.GetName()))
|
|
|
|
newDir := filepath.Join(ms.root, escapeFilename(newNet.GetName()))
|
|
|
|
// Avoid loosing data by overwriting an existing directory
|
|
|
|
if _, err := os.Stat(newDir); err == nil {
|
|
|
|
return fmt.Errorf("destination %q already exists", newDir)
|
|
|
|
}
|
|
|
|
return os.Rename(oldDir, newDir)
|
|
|
|
}
|
|
|
|
|
2021-05-18 14:50:19 +00:00
|
|
|
func truncateDay(t time.Time) time.Time {
|
|
|
|
year, month, day := t.Date()
|
|
|
|
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
|
|
|
|
}
|
|
|
|
|
|
|
|
func truncateSecond(t time.Time) time.Time {
|
|
|
|
year, month, day := t.Date()
|
|
|
|
return time.Date(year, month, day, t.Hour(), t.Minute(), t.Second(), 0, t.Location())
|
|
|
|
}
|
|
|
|
|
|
|
|
func isTimeBetween(t, start, end time.Time) bool {
|
|
|
|
if end.Before(start) {
|
|
|
|
end, start = start, end
|
|
|
|
}
|
|
|
|
return start.Before(t) && t.Before(end)
|
|
|
|
}
|