1// Copyright (c) 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2// released under the MIT license
3
4package irc
5
6import (
7	"bufio"
8	"fmt"
9	"os"
10	"strconv"
11	"time"
12
13	"github.com/ergochat/ergo/irc/history"
14	"github.com/ergochat/ergo/irc/modes"
15	"github.com/ergochat/ergo/irc/utils"
16)
17
18const (
19	histservHelp = `HistServ provides commands related to history.`
20)
21
22func histservEnabled(config *Config) bool {
23	return config.History.Enabled
24}
25
26func historyComplianceEnabled(config *Config) bool {
27	return config.History.Enabled && config.History.Persistent.Enabled && config.History.Retention.EnableAccountIndexing
28}
29
30var (
31	histservCommands = map[string]*serviceCommand{
32		"forget": {
33			handler: histservForgetHandler,
34			help: `Syntax: $bFORGET <account>$b
35
36FORGET deletes all history messages sent by an account.`,
37			helpShort: `$bFORGET$b deletes all history messages sent by an account.`,
38			capabs:    []string{"history"},
39			enabled:   histservEnabled,
40			minParams: 1,
41			maxParams: 1,
42		},
43		"delete": {
44			handler: histservDeleteHandler,
45			help: `Syntax: $bDELETE [target] <msgid>$b
46
47DELETE deletes an individual message by its msgid. The target is a channel
48name or nickname; depending on the history implementation, this may or may not
49be necessary to locate the message.`,
50			helpShort: `$bDELETE$b deletes an individual message by its msgid.`,
51			enabled:   histservEnabled,
52			minParams: 1,
53			maxParams: 2,
54		},
55		"export": {
56			handler: histservExportHandler,
57			help: `Syntax: $bEXPORT <account>$b
58
59EXPORT exports all messages sent by an account as JSON. This can be used at
60the request of the account holder.`,
61			helpShort: `$bEXPORT$b exports all messages sent by an account as JSON.`,
62			enabled:   historyComplianceEnabled,
63			capabs:    []string{"history"},
64			minParams: 1,
65			maxParams: 1,
66		},
67		"play": {
68			handler: histservPlayHandler,
69			help: `Syntax: $bPLAY <target> [limit]$b
70
71PLAY plays back history messages, rendering them into direct messages from
72HistServ. 'target' is a channel name or nickname to query, and 'limit'
73is a message count or a time duration. Note that message playback may be
74incomplete or degraded, relative to direct playback from /HISTORY or
75CHATHISTORY.`,
76			helpShort: `$bPLAY$b plays back history messages.`,
77			enabled:   histservEnabled,
78			minParams: 1,
79			maxParams: 2,
80		},
81	}
82)
83
84func histservForgetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
85	accountName := server.accounts.AccountToAccountName(params[0])
86	if accountName == "" {
87		service.Notice(rb, client.t("Could not look up account name, proceeding anyway"))
88		accountName = params[0]
89	}
90
91	server.ForgetHistory(accountName)
92
93	service.Notice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
94}
95
96func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
97	var target, msgid string
98	if len(params) == 1 {
99		msgid = params[0]
100	} else {
101		target, msgid = params[0], params[1]
102	}
103
104	// operators can delete; if individual delete is allowed, a chanop or
105	// the message author can delete
106	accountName := "*"
107	isChanop := false
108	isOper := client.HasRoleCapabs("history")
109	if !isOper {
110		if server.Config().History.Retention.AllowIndividualDelete {
111			channel := server.channels.Get(target)
112			if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
113				isChanop = true
114			} else {
115				accountName = client.AccountName()
116			}
117		}
118	}
119	if !isOper && !isChanop && accountName == "*" {
120		service.Notice(rb, client.t("Insufficient privileges"))
121		return
122	}
123
124	err := server.DeleteMessage(target, msgid, accountName)
125	if err == nil {
126		service.Notice(rb, client.t("Successfully deleted message"))
127	} else {
128		if isOper {
129			service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
130		} else {
131			service.Notice(rb, client.t("Could not delete message"))
132		}
133	}
134}
135
136func histservExportHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
137	cfAccount, err := CasefoldName(params[0])
138	if err != nil {
139		service.Notice(rb, client.t("Invalid account name"))
140		return
141	}
142
143	config := server.Config()
144	// don't include the account name in the filename because of escaping concerns
145	filename := fmt.Sprintf("%s-%s.json", utils.GenerateSecretToken(), time.Now().UTC().Format(IRCv3TimestampFormat))
146	pathname := config.getOutputPath(filename)
147	outfile, err := os.Create(pathname)
148	if err != nil {
149		service.Notice(rb, fmt.Sprintf(client.t("Error opening export file: %v"), err))
150	} else {
151		service.Notice(rb, fmt.Sprintf(client.t("Started exporting data for account %[1]s to file %[2]s"), cfAccount, filename))
152	}
153
154	go histservExportAndNotify(service, server, cfAccount, outfile, filename, client.Nick())
155}
156
157func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
158	defer server.HandlePanic()
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, service.prefix, "NOTICE", client.Nick(), fmt.Sprintf(client.t("Data export for %[1]s completed and written to %[2]s"), cfAccount, filename))
169	}
170}
171
172func histservPlayHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
173	items, _, err := easySelectHistory(server, client, params)
174	if err != nil {
175		service.Notice(rb, client.t("Could not retrieve history"))
176		return
177	}
178
179	playMessage := func(timestamp time.Time, nick, message string) {
180		service.Notice(rb, fmt.Sprintf("%s <%s> %s", timestamp.Format("15:04:05"), NUHToNick(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	service.Notice(rb, client.t("End of history playback"))
198}
199
200// handles parameter parsing and history queries for /HISTORY and /HISTSERV PLAY
201func easySelectHistory(server *Server, client *Client, params []string) (items []history.Item, channel *Channel, err error) {
202	channel, sequence, err := server.GetHistorySequence(nil, client, params[0])
203
204	if sequence == nil || err != nil {
205		return nil, nil, errNoSuchChannel
206	}
207
208	var duration time.Duration
209	maxChathistoryLimit := server.Config().History.ChathistoryMax
210	limit := 100
211	if maxChathistoryLimit < limit {
212		limit = maxChathistoryLimit
213	}
214	if len(params) > 1 {
215		providedLimit, err := strconv.Atoi(params[1])
216		if err == nil && providedLimit != 0 {
217			limit = providedLimit
218			if maxChathistoryLimit < limit {
219				limit = maxChathistoryLimit
220			}
221		} else if err != nil {
222			duration, err = time.ParseDuration(params[1])
223			if err == nil {
224				limit = maxChathistoryLimit
225			}
226		}
227	}
228
229	if duration == 0 {
230		items, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
231	} else {
232		now := time.Now().UTC()
233		start := history.Selector{Time: now}
234		end := history.Selector{Time: now.Add(-duration)}
235		items, err = sequence.Between(start, end, limit)
236	}
237	return
238}
239