diff --git a/docs/API.md b/docs/API.md index cf955ee..c57bc4f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -277,4 +277,35 @@ The response JSON given to the chat page from /api/profile looks like: The "Remaining" integer in the result shows how many older messages still remain to be retrieved, and tells the front-end page that it can request -another page. \ No newline at end of file +another page. + +## POST /api/message/clear + +Clear stored direct message history for a user. + +This endpoint can be called by the user themself (using JWT token authorization), +or by your website (using your admin APIKey) so your site can also clear chat +history remotely (e.g., for when your user deleted their account). + +The request body payload looks like: + +```javascript +{ + // when called from the BareRTC frontend for the current user + "JWTToken": "the caller's chat jwt token", + + // when called from your website + "APIKey": "your AdminAPIKey from settings.toml", + "Username": "soandso" +} +``` + +The response JSON given to the chat page looks like: + +```javascript +{ + "OK": true, + "Error": "only on error messages", + "MessagesErased": 42 +} +``` diff --git a/pkg/api.go b/pkg/api.go index 239bafc..38a2d9c 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -881,6 +881,128 @@ func (s *Server) MessageHistory() http.HandlerFunc { }) } +// ClearMessages (/api/message/clear) deletes all the stored direct messages for a user. +// +// It can be called by the authenticated user themself (with JWTToken), or from your website +// (with APIKey) in which case you can remotely clear history for a user. +// +// It is a POST request with a json body containing the following schema: +// +// { +// "JWTToken": "the caller's jwt token", +// "APIKey": "your website's admin API key" +// "Username": "if using your APIKey to specify a user to delete", +// } +// +// The response JSON will look like the following: +// +// { +// "OK": true, +// "Error": "only on error responses", +// "MessagesErased": 123, +// } +// +// The Remaining value is how many older messages still exist to be loaded. +func (s *Server) ClearMessages() http.HandlerFunc { + type request struct { + JWTToken string + APIKey string + Username string + } + + type result struct { + OK bool + Error string `json:",omitempty"` + MessagesErased int `json:""` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // JSON writer for the response. + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + // Parse the request. + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only POST methods allowed", + }) + return + } else if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only application/json content-types allowed", + }) + return + } + + defer r.Body.Close() + + // Parse the request payload. + var ( + params request + dec = json.NewDecoder(r.Body) + ) + if err := dec.Decode(¶ms); err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Authenticate this request. + if params.APIKey != "" { + // By admin API key. + if params.APIKey != config.Current.AdminAPIKey { + w.WriteHeader(http.StatusUnauthorized) + enc.Encode(result{ + Error: "Authentication denied.", + }) + return + } + } else { + // Are JWT tokens enabled on the server? + if !config.Current.JWT.Enabled || params.JWTToken == "" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "JWT authentication is not available.", + }) + return + } + + // Validate the user's JWT token. + claims, _, err := jwt.ParseAndValidate(params.JWTToken) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Set the username to clear. + params.Username = claims.Subject + } + + // Erase their message history. + count, err := (models.DirectMessage{}).ClearMessages(params.Username) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + enc.Encode(result{ + OK: true, + MessagesErased: count, + }) + }) +} + // Blocklist cache sent over from your website. var ( // Map of username to the list of usernames they block. diff --git a/pkg/models/direct_messages.go b/pkg/models/direct_messages.go index f9cf4ad..258edd5 100644 --- a/pkg/models/direct_messages.go +++ b/pkg/models/direct_messages.go @@ -82,6 +82,42 @@ func (dm DirectMessage) LogMessage(fromUsername, toUsername string, msg messages return err } +// ClearMessages clears all stored DMs that the username as a participant in. +func (dm DirectMessage) ClearMessages(username string) (int, error) { + if DB == nil { + return 0, ErrNotInitialized + } + + var placeholders = []interface{}{ + fmt.Sprintf("@%s:%%", username), // `@alice:%` + fmt.Sprintf("%%:@%s", username), // `%:@alice` + username, + } + + // Count all the messages we'll delete. + var ( + count int + row = DB.QueryRow(` + SELECT COUNT(message_id) + FROM direct_messages + WHERE (channel_id LIKE ? OR channel_id LIKE ?) + OR username = ? + `, placeholders...) + ) + if err := row.Scan(&count); err != nil { + return 0, err + } + + // Delete them all. + _, err := DB.Exec(` + DELETE FROM direct_messages + WHERE (channel_id LIKE ? OR channel_id LIKE ?) + OR username = ? + `, placeholders...) + + return count, err +} + // TakebackMessage removes a message by its MID from the DM history. // // Because the MessageID may have been from a previous chat session, the server can't immediately diff --git a/pkg/server.go b/pkg/server.go index d0dc821..725434a 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -58,6 +58,7 @@ func (s *Server) Setup() error { mux.Handle("/api/shutdown", s.ShutdownAPI()) mux.Handle("/api/profile", s.UserProfile()) mux.Handle("/api/message/history", s.MessageHistory()) + mux.Handle("/api/message/clear", s.ClearMessages()) mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static")))) diff --git a/src/App.vue b/src/App.vue index ce0dbb9..198af52 100644 --- a/src/App.vue +++ b/src/App.vue @@ -282,6 +282,12 @@ export default { } */ }, + clearDirectMessages: { + busy: false, + ok: false, + messagesErased: 0, + timeout: null, + }, // Responsive CSS controls for mobile. responsive: { @@ -1037,6 +1043,13 @@ export default { return; } + // Clear user chat history. + if (this.message.toLowerCase().indexOf("/clear-history") === 0) { + this.clearMessageHistory(); + this.message = ""; + return; + } + // console.debug("Send message: %s", this.message); this.client.send({ action: "message", @@ -3277,6 +3290,65 @@ export default { this.directMessageHistory[channel].busy = false; }); }, + async clearMessageHistory(prompt = false) { + if (!this.jwt.valid || this.clearDirectMessages.busy) return; + + if (prompt) { + if (!window.confirm( + "This will delete all of your DMs history stored on the server. People you have " + + "chatted with will have their past messages sent to you erased as well.\n\n" + + "Note: messages that are currently displayed on your chat partner's screen will " + + "NOT be removed by this action -- if this is a concern and you want to 'take back' " + + "a message from their screen, use the 'take back' button (red arrow circle) on the " + + "message you sent to them. The 'clear history' button only clears the database, but " + + "does not send takebacks to pull the message from everybody else's screen.\n\n" + + "Are you sure you want to clear your stored DMs history on the server?", + )) { + return; + } + } + + if (this.clearDirectMessages.timeout !== null) { + clearTimeout(this.clearDirectMessages.timeout); + } + + this.clearDirectMessages.busy = true; + return fetch("/api/message/clear", { + method: "POST", + mode: "same-origin", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "JWTToken": this.jwt.token, + }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.Error) { + console.error("ClearMessageHistory: ", data.Error); + return; + } + + this.clearDirectMessages.ok = true; + this.clearDirectMessages.messagesErased = data.MessagesErased; + this.clearDirectMessages.timeout = setTimeout(() => { + this.clearDirectMessages.ok = false; + }, 15000); + + this.ChatClient( + "Your direct message history has been cleared from the server's database. "+ + "(" + data.MessagesErased + " messages erased)", + ); + }).catch(resp => { + console.error("DirectMessageHistory: ", resp); + this.ChatClient("Error clearing your chat history: " + resp); + }).finally(() => { + this.clearDirectMessages.busy = false; + }); + }, /* * Webhook methods @@ -3387,6 +3459,11 @@ export default { Misc +
  • + + Advanced + +
  • @@ -3760,7 +3837,7 @@ export default {
    -
    + +
    + + Clear direct message history + + +
    + + Working... +
    +
    + + History cleared ({{ clearDirectMessages.messagesErased }} message{{ clearDirectMessages.messagesErased === 1 ? '' : 's' }} erased) +
    +
    + + + + +
    +