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