From 9213f2a078d00ee7777f11aeb6e481573c9dbe5b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 17 Sep 2020 23:50:07 +0100 Subject: [PATCH] Add account deletion with email notification Select users to delete, then optionally opt to notify the user in an email with a provided reason. --- api.go | 45 +++++++++++++++++++++++++ config.go | 3 ++ config/config-base.json | 30 +++++++++++++++++ data/config-base.json | 36 ++++++++++++++++++++ data/static/accounts.js | 70 +++++++++++++++++++++++++++++++++++++-- data/static/admin.js | 5 +-- data/templates/admin.html | 26 +++++++++++++++ email.go | 26 +++++++++++++++ jfapi.go | 11 ++++++ mail/deleted.mjml | 36 ++++++++++++++++++++ mail/deleted.txt | 4 +++ main.go | 1 + 12 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 mail/deleted.mjml create mode 100644 mail/deleted.txt diff --git a/api.go b/api.go index 24dfbbc..bd471e2 100644 --- a/api.go +++ b/api.go @@ -274,6 +274,51 @@ func (app *appContext) NewUser(gc *gin.Context) { gc.JSON(200, validation) } +type deleteUserReq struct { + Users []string `json:"users"` + Notify bool `json:"notify"` + Reason string `json:"reason"` +} + +func (app *appContext) DeleteUser(gc *gin.Context) { + var req deleteUserReq + gc.BindJSON(&req) + errors := map[string]string{} + for _, userID := range req.Users { + status, err := app.jf.deleteUser(userID) + if !(status == 200 || status == 204) || err != nil { + errors[userID] = fmt.Sprintf("%d: %s", status, err) + } + if req.Notify { + addr, ok := app.storage.emails[userID] + if addr != nil && ok { + go func(userID, reason, address string) { + msg, err := app.email.constructDeleted(reason, app) + if err != nil { + app.err.Printf("%s: Failed to construct account deletion email", userID) + app.debug.Printf("%s: Error: %s", userID, err) + } else if err := app.email.send(address, msg); err != nil { + app.err.Printf("%s: Failed to send to %s", userID, address) + app.debug.Printf("%s: Error: %s", userID, err) + } else { + app.info.Printf("%s: Sent invite email to %s", userID, address) + } + }(userID, req.Reason, addr.(string)) + } + } + } + app.jf.cacheExpiry = time.Now() + if len(errors) == len(req.Users) { + respond(500, "Failed", gc) + app.err.Printf("Account deletion failed: %s", errors[req.Users[0]]) + return + } else if len(errors) != 0 { + gc.JSON(500, errors) + return + } + gc.JSON(200, map[string]bool{"success": true}) +} + type generateInviteReq struct { Days int `json:"days"` Hours int `json:"hours"` diff --git a/config.go b/config.go index e3b8688..ae5359d 100644 --- a/config.go +++ b/config.go @@ -69,6 +69,9 @@ func (app *appContext) loadConfig() error { app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html"))) app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt"))) + app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.local_path, "deleted.html"))) + app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.local_path, "deleted.txt"))) + app.email = NewEmailer(app) return nil diff --git a/config/config-base.json b/config/config-base.json index 5d37047..35a1280 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -543,6 +543,36 @@ "description": "API Key. Get this from the first tab in Ombi settings." } }, + "deletion": { + "meta": { + "name": "Account Deletion", + "description": "Subject/email files for account deletion emails." + }, + "subject": { + "name": "Email subject", + "required": false, + "requires_restart": false, + "type": "text", + "value": "Your account was deleted - Jellyfin", + "description": "Subject of account deletion emails." + }, + "email_html": { + "name": "Custom email (HTML)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email html" + }, + "email_text": { + "name": "Custom email (plaintext)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email in plain text" + } + }, "files": { "meta": { "name": "File Storage", diff --git a/data/config-base.json b/data/config-base.json index 750374d..cc94a48 100644 --- a/data/config-base.json +++ b/data/config-base.json @@ -10,6 +10,7 @@ "mailgun", "smtp", "ombi", + "deletion", "files" ], "jellyfin": { @@ -634,6 +635,41 @@ "description": "API Key. Get this from the first tab in Ombi settings." } }, + "deletion": { + "order": [ + "subject", + "email_html", + "email_text" + ], + "meta": { + "name": "Account Deletion", + "description": "Subject/email files for account deletion emails." + }, + "subject": { + "name": "Email subject", + "required": false, + "requires_restart": false, + "type": "text", + "value": "Your account was deleted - Jellyfin", + "description": "Subject of account deletion emails." + }, + "email_html": { + "name": "Custom email (HTML)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email html" + }, + "email_text": { + "name": "Custom email (plaintext)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email in plain text" + } + }, "files": { "order": [ "invites", diff --git a/data/static/accounts.js b/data/static/accounts.js index 30b1303..396a944 100644 --- a/data/static/accounts.js +++ b/data/static/accounts.js @@ -34,6 +34,73 @@ function checkCheckboxes() { } } +document.getElementById('deleteModalNotify').onclick = function() { + const textbox = document.getElementById('deleteModalReasonBox'); + if (this.checked && textbox.classList.contains('unfocused')) { + textbox.classList.remove('unfocused'); + } else if (!this.checked) { + textbox.classList.add('unfocused'); + } +}; + +document.getElementById('accountsTabDelete').onclick = function() { + const deleteButton = this; + let selected = []; + const checkboxes = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]'); + for (check of checkboxes) { + if (check.checked) { + selected.push(check.id.replace('select_', '')); + } + } + let title = " user"; + if (selected.length > 1) { + title += "s"; + } + title = "Delete " + selected.length + title; + document.getElementById('deleteModalTitle').textContent = title; + document.getElementById('deleteModalNotify').checked = false; + document.getElementById('deleteModalReason').value = ''; + document.getElementById('deleteModalReasonBox').classList.add('unfocused'); + document.getElementById('deleteModalSend').textContent = 'Delete'; + + document.getElementById('deleteModalSend').onclick = function() { + const button = this; + const send = { + 'users': selected, + 'notify': document.getElementById('deleteModalNotify').checked, + 'reason': document.getElementById('deleteModalReason').value + }; + let req = new XMLHttpRequest(); + req.open("POST", "/deleteUser", true); + req.responseType = 'json'; + req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + req.onreadystatechange = function() { + if (this.readyState == 4) { + if (this.status == 500) { + if ("error" in req.response) { + button.textContent = 'Failed'; + } else { + button.textContent = 'Partial fail (check console)'; + console.log(req.response); + } + setTimeout(function() { + deleteModal.hide(); + deleteButton.classList.add('unfocused'); + }, 4000); + } else { + deleteButton.classList.add('unfocused'); + deleteModal.hide(); + } + populateUsers(); + checkCheckboxes(); + } + }; + req.send(JSON.stringify(send)); + }; + deleteModal.show(); +} + var jfUsers = []; function validEmail(email) { @@ -68,7 +135,6 @@ function changeEmail(icon, id) { //this.remove(); let send = {}; send[id] = newEmail; - console.log(send); let req = new XMLHttpRequest(); req.open("POST", "/modifyEmails", true); req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); @@ -195,7 +261,7 @@ document.getElementById('accountsTabSetDefaults').onclick = function() { checked = ''; } radio.innerHTML = ` - `; + `; radioList.appendChild(radio); } let userstring = 'user'; diff --git a/data/static/admin.js b/data/static/admin.js index 39517b1..398b6d6 100644 --- a/data/static/admin.js +++ b/data/static/admin.js @@ -134,6 +134,7 @@ var usersModal = createModal('users'); var restartModal = createModal('restartModal'); var refreshModal = createModal('refreshModal'); var aboutModal = createModal('aboutModal'); +var deleteModal = createModal('deleteModal'); // Parsed invite: [, , <1: Empty invite (no delete/link), 0: Actual invite>, , , [], , , ] function parseInvite(invite, empty = false) { @@ -671,7 +672,7 @@ document.getElementById('openDefaultsWizard').onclick = function() { checked = ''; } radio.innerHTML = - ``; + ``; radioList.appendChild(radio); } let button = document.getElementById('openDefaultsWizard'); @@ -718,7 +719,7 @@ function storeDefaults(users) { let id = ''; for (let radio of radios) { if (radio.checked) { - id = radio.id.replace('select_', ''); + id = radio.id.replace('default_', ''); break; } } diff --git a/data/templates/admin.html b/data/templates/admin.html index 30e183d..4ba954d 100644 --- a/data/templates/admin.html +++ b/data/templates/admin.html @@ -244,6 +244,32 @@ +

invites accounts

diff --git a/email.go b/email.go index 9b13696..e26fb82 100644 --- a/email.go +++ b/email.go @@ -278,6 +278,32 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error) return email, nil } +func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) { + email := &Email{ + subject: app.config.Section("deletion").Key("subject").MustString("Your account was deleted - Jellyfin"), + } + for _, key := range []string{"html", "text"} { + fpath := app.config.Section("deletion").Key("email_" + key).String() + tpl, err := template.ParseFiles(fpath) + if err != nil { + return nil, err + } + var tplData bytes.Buffer + err = tpl.Execute(&tplData, map[string]string{ + "reason": reason, + }) + if err != nil { + return nil, err + } + if key == "html" { + email.html = tplData.String() + } else { + email.text = tplData.String() + } + } + return email, nil +} + // calls the send method in the underlying emailClient. func (emailer *Emailer) send(address string, email *Email) error { return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email) diff --git a/jfapi.go b/jfapi.go index 00d368b..236b113 100644 --- a/jfapi.go +++ b/jfapi.go @@ -215,6 +215,17 @@ func (jf *Jellyfin) _post(url string, data map[string]interface{}, response bool return "", resp.StatusCode, nil } +func (jf *Jellyfin) deleteUser(id string) (int, error) { + url := fmt.Sprintf("%s/Users/%s", jf.server, id) + req, _ := http.NewRequest("DELETE", url, nil) + for name, value := range jf.header { + req.Header.Add(name, value) + } + resp, err := jf.httpClient.Do(req) + defer timeoutHandler("Jellyfin", jf.server, jf.noFail) + return resp.StatusCode, err +} + func (jf *Jellyfin) getUsers(public bool) ([]map[string]interface{}, int, error) { var result []map[string]interface{} var data string diff --git a/mail/deleted.mjml b/mail/deleted.mjml new file mode 100644 index 0000000..78179e2 --- /dev/null +++ b/mail/deleted.mjml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + Jellyfin + + + + + +

Your account was deleted.

+

Reason: {{ .reason }}

+
+
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/deleted.txt b/mail/deleted.txt new file mode 100644 index 0000000..19c07fb --- /dev/null +++ b/mail/deleted.txt @@ -0,0 +1,4 @@ +Your Jellyfin account was deleted. +Reason: {{ .reason }} + +{{ .message }} diff --git a/main.go b/main.go index cd7ed05..7b6a482 100644 --- a/main.go +++ b/main.go @@ -439,6 +439,7 @@ func start(asDaemon, firstCall bool) { api.GET("/getInvites", app.GetInvites) api.POST("/setNotify", app.SetNotify) api.POST("/deleteInvite", app.DeleteInvite) + api.POST("/deleteUser", app.DeleteUser) api.GET("/getUsers", app.GetUsers) api.POST("/modifyEmails", app.ModifyEmails) api.POST("/setDefaults", app.SetDefaults)