From 918f8816c5c00cb6e90d20ce0fc475bfe3950c29 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 Jun 2023 21:32:18 +0100 Subject: [PATCH 01/54] auth: slight refactor, setup user auth user-auth.go contains slightly adjusted versions of auth.go functions, for authorizing jellyfin users (admin or not). Refactored auth.go so that most code is shared. User auth isn't hooked up yet, nor has it been tested. --- auth.go | 145 +++++++++++++++++++++++++++++++++++++-------------- main.go | 7 ++- user-auth.go | 98 ++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 42 deletions(-) create mode 100644 user-auth.go diff --git a/auth.go b/auth.go index 94344f6..8973a99 100644 --- a/auth.go +++ b/auth.go @@ -9,21 +9,28 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" + "github.com/hrfee/mediabrowser" "github.com/lithammer/shortuuid/v3" ) +const ( + TOKEN_VALIDITY_SEC = 20 * 60 + REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24 +) + func (app *appContext) webAuth() gin.HandlerFunc { return app.authenticate } // CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens. -func CreateToken(userId, jfId string) (string, string, error) { +func CreateToken(userId, jfId string, admin bool) (string, string, error) { var token, refresh string claims := jwt.MapClaims{ "valid": true, "id": userId, - "exp": time.Now().Add(time.Minute * 20).Unix(), + "exp": time.Now().Add(time.Second * TOKEN_VALIDITY_SEC).Unix(), "jfid": jfId, + "admin": admin, "type": "bearer", } tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) @@ -31,7 +38,7 @@ func CreateToken(userId, jfId string) (string, string, error) { if err != nil { return "", "", err } - claims["exp"] = time.Now().Add(time.Hour * 24).Unix() + claims["exp"] = time.Now().Add(time.Second * REFRESH_TOKEN_VALIDITY_SEC).Unix() claims["type"] = "refresh" tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) @@ -41,8 +48,9 @@ func CreateToken(userId, jfId string) (string, string, error) { return token, refresh, nil } -// Check header for token -func (app *appContext) authenticate(gc *gin.Context) { +// Caller should return if this returns false. +func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.MapClaims, ok bool) { + ok = false header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) if header[0] != "Bearer" { app.debug.Println("Invalid authorization header") @@ -55,7 +63,13 @@ func (app *appContext) authenticate(gc *gin.Context) { respond(401, "Unauthorized", gc) return } - claims, ok := token.Claims.(jwt.MapClaims) + claims, ok = token.Claims.(jwt.MapClaims) + if !ok { + app.debug.Println("Invalid JWT") + respond(401, "Unauthorized", gc) + return + } + ok = false expiryUnix := int64(claims["exp"].(float64)) if err != nil { app.debug.Printf("Auth denied: %s", err) @@ -68,10 +82,27 @@ func (app *appContext) authenticate(gc *gin.Context) { respond(401, "Unauthorized", gc) return } + ok = true + return +} + +// Check header for token +func (app *appContext) authenticate(gc *gin.Context) { + claims, ok := app.decodeValidateAuthHeader(gc) + if !ok { + return + } + isAdminToken := claims["admin"].(bool) + if !isAdminToken { + app.debug.Printf("Auth denied: Token was not for admin access") + respond(401, "Unauthorized", gc) + return + } + userID := claims["id"].(string) jfID := claims["jfid"].(string) match := false - for _, user := range app.users { + for _, user := range app.adminUsers { if user.UserID == userID { match = true break @@ -84,6 +115,7 @@ func (app *appContext) authenticate(gc *gin.Context) { } gc.Set("jfId", jfID) gc.Set("userId", userID) + gc.Set("userMode", false) app.debug.Println("Auth succeeded") gc.Next() } @@ -99,6 +131,39 @@ type getTokenDTO struct { Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else. } +func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) { + header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) + auth, _ := base64.StdEncoding.DecodeString(header[1]) + creds := strings.SplitN(string(auth), ":", 2) + username = creds[0] + password = creds[1] + ok = false + if username == "" || password == "" { + app.debug.Println("Auth denied: blank username/password") + respond(401, "Unauthorized", gc) + return + } + ok = true + return +} + +func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) { + ok = false + user, status, err := app.authJf.Authenticate(username, password) + if status != 200 || err != nil { + if status == 401 || status == 400 { + app.info.Println("Auth denied: Invalid username/password (Jellyfin)") + respond(401, "Unauthorized", gc) + return + } + app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err) + respond(500, "Jellyfin error", gc) + return + } + ok = true + return +} + // @Summary Grabs an API token using username & password. // @description If viewing docs locally, click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`". // @Produce json @@ -109,18 +174,14 @@ type getTokenDTO struct { // @Security getTokenAuth func (app *appContext) getTokenLogin(gc *gin.Context) { app.info.Println("Token requested (login attempt)") - header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) - auth, _ := base64.StdEncoding.DecodeString(header[1]) - creds := strings.SplitN(string(auth), ":", 2) - var userID, jfID string - if creds[0] == "" || creds[1] == "" { - app.debug.Println("Auth denied: blank username/password") - respond(401, "Unauthorized", gc) + username, password, ok := app.decodeValidateLoginHeader(gc) + if !ok { return } + var userID, jfID string match := false - for _, user := range app.users { - if user.Username == creds[0] && user.Password == creds[1] { + for _, user := range app.adminUsers { + if user.Username == username && user.Password == password { match = true app.debug.Println("Found existing user") userID = user.UserID @@ -133,15 +194,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) { return } if !match { - user, status, err := app.authJf.Authenticate(creds[0], creds[1]) - if status != 200 || err != nil { - if status == 401 || status == 400 { - app.info.Println("Auth denied: Invalid username/password (Jellyfin)") - respond(401, "Unauthorized", gc) - return - } - app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err) - respond(500, "Jellyfin error", gc) + user, ok := app.validateJellyfinCredentials(username, password, gc) + if !ok { return } jfID = user.ID @@ -153,7 +207,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) { } accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator) if !accountsAdmin { - app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0]) + app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username) respond(401, "Unauthorized", gc) return } @@ -163,10 +217,10 @@ func (app *appContext) getTokenLogin(gc *gin.Context) { newUser := User{ UserID: userID, } - app.debug.Printf("Token generated for user \"%s\"", creds[0]) - app.users = append(app.users, newUser) + app.debug.Printf("Token generated for user \"%s\"", username) + app.adminUsers = append(app.adminUsers, newUser) } - token, refresh, err := CreateToken(userID, jfID) + token, refresh, err := CreateToken(userID, jfID, true) if err != nil { app.err.Printf("getToken failed: Couldn't generate token (%s)", err) respond(500, "Couldn't generate token", gc) @@ -176,14 +230,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) { gc.JSON(200, getTokenDTO{token}) } -// @Summary Grabs an API token using a refresh token from cookies. -// @Produce json -// @Success 200 {object} getTokenDTO -// @Failure 401 {object} stringResponse -// @Router /token/refresh [get] -// @tags Auth -func (app *appContext) getTokenRefresh(gc *gin.Context) { - app.debug.Println("Token requested (refresh token)") +func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context) (claims jwt.MapClaims, ok bool) { + ok = false cookie, err := gc.Cookie("refresh") if err != nil || cookie == "" { app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err) @@ -203,27 +251,44 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) { respond(400, "Invalid token", gc) return } - claims, ok := token.Claims.(jwt.MapClaims) + claims, ok = token.Claims.(jwt.MapClaims) expiryUnix := int64(claims["exp"].(float64)) if err != nil { app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err) respond(401, "Invalid token", gc) return } + ok = false expiry := time.Unix(expiryUnix, 0) if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) { app.debug.Printf("getTokenRefresh: Invalid token: %s", err) respond(401, "Invalid token", gc) return } + ok = true + return +} + +// @Summary Grabs an API token using a refresh token from cookies. +// @Produce json +// @Success 200 {object} getTokenDTO +// @Failure 401 {object} stringResponse +// @Router /token/refresh [get] +// @tags Auth +func (app *appContext) getTokenRefresh(gc *gin.Context) { + app.debug.Println("Token requested (refresh token)") + claims, ok := app.decodeValidateRefreshCookie(gc) + if !ok { + return + } userID := claims["id"].(string) jfID := claims["jfid"].(string) - jwt, refresh, err := CreateToken(userID, jfID) + jwt, refresh, err := CreateToken(userID, jfID, true) if err != nil { app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err) respond(500, "Couldn't generate token", gc) return } - gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true) + gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true) gc.JSON(200, getTokenDTO{jwt}) } diff --git a/main.go b/main.go index fb6c725..64ac367 100644 --- a/main.go +++ b/main.go @@ -87,7 +87,7 @@ type appContext struct { webFS httpFS cssClass string // Default theme, "light"|"dark". jellyfinLogin bool - users []User + adminUsers []User invalidTokens []string // Keeping jf name because I can't think of a better one jf *mediabrowser.MediaBrowser @@ -450,7 +450,7 @@ func start(asDaemon, firstCall bool) { user.UserID = shortuuid.New() user.Username = app.config.Section("ui").Key("username").String() user.Password = app.config.Section("ui").Key("password").String() - app.users = append(app.users, user) + app.adminUsers = append(app.adminUsers, user) } else { app.debug.Println("Using Jellyfin for authentication") app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout) @@ -645,6 +645,9 @@ func flagPassed(name string) (found bool) { // @securityDefinitions.basic getTokenAuth // @name getTokenAuth +// @securityDefinitions.basic getUserTokenAuth +// @name getUserTokenAuth + // @tag.name Auth // @tag.description -Get a token here if running swagger UI locally.- diff --git a/user-auth.go b/user-auth.go new file mode 100644 index 0000000..bc26d4b --- /dev/null +++ b/user-auth.go @@ -0,0 +1,98 @@ +package main + +import "github.com/gin-gonic/gin" + +func (app *appContext) userAuth() gin.HandlerFunc { + return app.userAuthenticate +} + +func (app *appContext) userAuthenticate(gc *gin.Context) { + jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true) + if !jellyfinLogin { + app.err.Println("Enable Jellyfin Login to use the User Page feature.") + respond(500, "Contact Admin", gc) + return + } + claims, ok := app.decodeValidateAuthHeader(gc) + if !ok { + return + } + + // user id can be nil for all we care, we just want the Jellyfin ID + jfID := claims["jfid"].(string) + + gc.Set("jfId", jfID) + gc.Set("userMode", true) + app.debug.Println("Auth succeeded") + gc.Next() +} + +// @Summary Grabs an user-access token using username & password. +// @description Has limited access to API routes, used to display the user's personal page. +// @Produce json +// @Success 200 {object} getTokenDTO +// @Failure 401 {object} stringResponse +// @Router /my/token/login [get] +// @tags Auth +// @Security getUserTokenAuth +func (app *appContext) getUserTokenLogin(gc *gin.Context) { + if !app.config.Section("ui").Key("jellyfin_login").MustBool(true) { + app.err.Println("Enable Jellyfin Login to use the User Page feature.") + respond(500, "Contact Admin", gc) + return + } + app.info.Println("UserToken requested (login attempt)") + username, password, ok := app.decodeValidateLoginHeader(gc) + if !ok { + return + } + + user, ok := app.validateJellyfinCredentials(username, password, gc) + if !ok { + return + } + + token, refresh, err := CreateToken(user.ID, user.ID, false) + if err != nil { + app.err.Printf("getUserToken failed: Couldn't generate user token (%s)", err) + respond(500, "Couldn't generate user token", gc) + return + } + + app.debug.Printf("Token generated for non-admin user \"%s\"", username) + gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", gc.Request.URL.Hostname(), true, true) + gc.JSON(200, getTokenDTO{token}) +} + +// @Summary Grabs an user-access token using a refresh token from cookies. +// @Produce json +// @Success 200 {object} getTokenDTO +// @Failure 401 {object} stringResponse +// @Router /my/token/refresh [get] +// @tags Auth +func (app *appContext) getUserTokenRefresh(gc *gin.Context) { + jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true) + if !jellyfinLogin { + app.err.Println("Enable Jellyfin Login to use the User Page feature.") + respond(500, "Contact Admin", gc) + return + } + + app.info.Println("UserToken request (refresh token)") + claims, ok := app.decodeValidateRefreshCookie(gc) + if !ok { + return + } + + jfID := claims["jfid"].(string) + + jwt, refresh, err := CreateToken(jfID, jfID, false) + if err != nil { + app.err.Printf("getUserToken failed: Couldn't generate user token (%s)", err) + respond(500, "Couldn't generate user token", gc) + return + } + + gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", gc.Request.URL.Hostname(), true, true) + gc.JSON(200, getTokenDTO{jwt}) +} From 81372d6a6b08edc1ff195f718c9762c8f1d9de39 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 Jun 2023 21:59:34 +0100 Subject: [PATCH 02/54] auth: fix "ok" issue the "ok" returned when the JWT claims are read was being overridden with "false" before it could be checked. --- auth.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/auth.go b/auth.go index 8973a99..b7eb186 100644 --- a/auth.go +++ b/auth.go @@ -69,17 +69,19 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map respond(401, "Unauthorized", gc) return } - ok = false expiryUnix := int64(claims["exp"].(float64)) if err != nil { app.debug.Printf("Auth denied: %s", err) respond(401, "Unauthorized", gc) + ok = false return } expiry := time.Unix(expiryUnix, 0) if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) { app.debug.Printf("Auth denied: Invalid token") + // app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string)) respond(401, "Unauthorized", gc) + ok = false return } ok = true @@ -256,13 +258,14 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context) (claims jwt. if err != nil { app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err) respond(401, "Invalid token", gc) + ok = false return } - ok = false expiry := time.Unix(expiryUnix, 0) if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) { - app.debug.Printf("getTokenRefresh: Invalid token: %s", err) + app.debug.Printf("getTokenRefresh: Invalid token: %+v", err) respond(401, "Invalid token", gc) + ok = false return } ok = true From b13fe7f3e48ee26e57c0e5656e71239f19cef02c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 Jun 2023 22:00:08 +0100 Subject: [PATCH 03/54] html: move login modal to own file --- html/admin.html | 12 +----------- html/login-modal.html | 11 +++++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 html/login-modal.html diff --git a/html/admin.html b/html/admin.html index a02b44f..bd17f32 100644 --- a/html/admin.html +++ b/html/admin.html @@ -22,17 +22,7 @@ {{ template "header.html" . }} - + {{ template "login-modal.html" . }} diff --git a/lang/admin/da-dk.json b/lang/admin/da-dk.json index ce1de62..3b117ce 100644 --- a/lang/admin/da-dk.json +++ b/lang/admin/da-dk.json @@ -18,7 +18,6 @@ "create": "Opret", "apply": "Anvend", "delete": "Slet", - "add": "Tilføj", "select": "Vælg", "name": "Navn", "date": "Dato", @@ -46,7 +45,6 @@ "conditionals": "Betingelser", "preview": "Eksempel", "reset": "Nulstil", - "edit": "Rediger", "donate": "Doner", "contactThrough": "Kontakt gennem:", "extendExpiry": "Forlæng udløb", diff --git a/lang/admin/de-de.json b/lang/admin/de-de.json index 1c4de83..1004d5c 100644 --- a/lang/admin/de-de.json +++ b/lang/admin/de-de.json @@ -66,7 +66,6 @@ "variables": "Variablen", "preview": "Vorschau", "reset": "Zurücksetzen", - "edit": "Bearbeiten", "customizeMessages": "Benachrichtigungen anpassen", "customizeMessagesDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.", "announce": "Ankündigen", @@ -87,7 +86,6 @@ "contactThrough": "Kontakt über:", "sendPIN": "Bitte den Benutzer, die unten stehende PIN an den Bot zu senden.", "inviteMonths": "Monate", - "add": "Hinzufügen", "select": "Auswählen", "searchDiscordUser": "Gib den Discord-Benutzername ein, um den Benutzer zu finden.", "findDiscordUser": "Suche Discord-Benutzer", diff --git a/lang/admin/el-gr.json b/lang/admin/el-gr.json index 3b97023..dc358df 100644 --- a/lang/admin/el-gr.json +++ b/lang/admin/el-gr.json @@ -66,7 +66,6 @@ "variables": "Μεταβλητές", "preview": "Προεπισκόπηση", "reset": "Επαναφορά", - "edit": "Επεξεργασία", "customizeMessages": "Παραμετροποίηση Emails", "advancedSettings": "Προχωρημένες Ρυθμίσεις", "customizeMessagesDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.", diff --git a/lang/admin/en-gb.json b/lang/admin/en-gb.json index 01cca19..44a3cf8 100644 --- a/lang/admin/en-gb.json +++ b/lang/admin/en-gb.json @@ -93,12 +93,10 @@ "contactThrough": "Contact through:", "select": "Select", "date": "Date", - "edit": "Edit", "extendExpiry": "Extend expiry", "sendPWR": "Send Password Reset", "inviteMonths": "Months", "inviteDuration": "Invite Duration", - "add": "Add", "update": "Update", "user": "User", "userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.", diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 7174ea8..c1eb7e4 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -18,7 +18,6 @@ "create": "Create", "apply": "Apply", "delete": "Delete", - "add": "Add", "select": "Select", "name": "Name", "date": "Date", @@ -51,7 +50,6 @@ "conditionals": "Conditionals", "preview": "Preview", "reset": "Reset", - "edit": "Edit", "donate": "Donate", "unlink": "Unlink Account", "sendPWR": "Send Password Reset", diff --git a/lang/admin/es-es.json b/lang/admin/es-es.json index 3a57fb3..6e41364 100644 --- a/lang/admin/es-es.json +++ b/lang/admin/es-es.json @@ -43,7 +43,6 @@ "variables": "Variables", "preview": "Vista previa", "reset": "Reiniciar", - "edit": "Editar", "extendExpiry": "Extender el vencimiento", "customizeMessages": "Personalizar mensajes", "customizeMessagesDescription": "Si no desea utilizar las plantillas de mensajes de jfa-go, puede crear las suyas con Markdown.", @@ -85,7 +84,6 @@ "notifyUserCreation": "Sobre la creación de usuarios", "conditionals": "Condicionales", "donate": "Donar", - "add": "Agregar", "templates": "Plantillas", "contactThrough": "Contactar a través de:", "select": "Seleccionar", diff --git a/lang/admin/fr-fr.json b/lang/admin/fr-fr.json index c7937d0..2a5809e 100644 --- a/lang/admin/fr-fr.json +++ b/lang/admin/fr-fr.json @@ -73,7 +73,6 @@ "variables": "Variables", "preview": "Aperçu", "reset": "Réinitialisation", - "edit": "Éditer", "customizeMessages": "Personnaliser les e-mails", "inviteDuration": "Durée de l'invitation", "advancedSettings": "Paramètres avancés", @@ -88,7 +87,6 @@ "extendExpiry": "Prolonger l'expiration", "contactThrough": "Contacté par :", "sendPIN": "Demandez à l'utilisateur d'envoyer le code PIN ci-dessous au bot.", - "add": "Ajouter", "select": "Sélectionner", "findDiscordUser": "Trouver l'utilisateur Discord", "linkMatrixDescription": "Entrez le nom d'utilisateur et le mot de passe de l'utilisateur pour l’utilisateur comme bot. Une fois soumis, l'application va redémarrer.", diff --git a/lang/admin/hu-hu.json b/lang/admin/hu-hu.json index d6e5470..9ad59ed 100644 --- a/lang/admin/hu-hu.json +++ b/lang/admin/hu-hu.json @@ -18,7 +18,6 @@ "create": "Létrehozás", "apply": "Alkalmaz", "delete": "Törlés", - "add": "Hozzáadás", "select": "Kiválasztás", "name": "Név", "date": "Dátum", @@ -49,7 +48,6 @@ "conditionals": "Feltételek", "preview": "Előnézet", "reset": "Visszaállítás", - "edit": "Szerkesztés", "donate": "Támogatás", "sendPWR": "Jelszó visszaállítás küldése", "contactThrough": "", diff --git a/lang/admin/id-id.json b/lang/admin/id-id.json index 6161673..71a1869 100644 --- a/lang/admin/id-id.json +++ b/lang/admin/id-id.json @@ -66,7 +66,6 @@ "variables": "Variabel", "preview": "Pratinjau", "reset": "Setel ulang", - "edit": "Edit", "customizeMessages": "Sesuaikan Email", "customizeMessagesDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.", "announce": "Mengumumkan", diff --git a/lang/admin/nl-nl.json b/lang/admin/nl-nl.json index 910202c..0464d96 100644 --- a/lang/admin/nl-nl.json +++ b/lang/admin/nl-nl.json @@ -71,7 +71,6 @@ "customizeMessagesDescription": "Als je de e-mailsjablonen van jfa-go niet wilt gebruiken, kun je met gebruik van Markdown je eigen aanmaken.", "preview": "Voorbeeld", "reset": "Reset", - "edit": "Bewerken", "customizeMessages": "E-mails aanpassen", "inviteDuration": "Geldigheidsduur uitnodiging", "userExpiryDescription": "Een bepaalde tijd na elke aanmelding, wordt de account verwijderd/uitgeschakeld door jfa-go. Dit kan aangepast worden in de instellingen.", @@ -87,7 +86,6 @@ "donate": "Doneer", "contactThrough": "Stuur bericht via:", "sendPIN": "Vraag de gebruiker om onderstaande pincode naar de bot te sturen.", - "add": "Voeg toe", "searchDiscordUser": "Begin de Discord gebruikersnaam te typen om de gebruiker te vinden.", "linkMatrixDescription": "Vul de gebruikersnaam en wachtwoord in van de gebruiker om als bot te gebruiken. De app start zodra ze zijn verstuurd.", "select": "Selecteer", diff --git a/lang/admin/pl-pl.json b/lang/admin/pl-pl.json index b3957de..522fb02 100644 --- a/lang/admin/pl-pl.json +++ b/lang/admin/pl-pl.json @@ -18,7 +18,6 @@ "create": "", "apply": "", "delete": "", - "add": "", "select": "", "name": "Imię", "date": "Data", @@ -49,7 +48,6 @@ "conditionals": "", "preview": "", "reset": "Zresetuj", - "edit": "Edytuj", "donate": "", "sendPWR": "", "contactThrough": "", diff --git a/lang/admin/pt-br.json b/lang/admin/pt-br.json index f84c2b9..2073940 100644 --- a/lang/admin/pt-br.json +++ b/lang/admin/pt-br.json @@ -71,7 +71,6 @@ "variables": "Variáveis", "preview": "Pre-visualizar", "reset": "Redefinir", - "edit": "Editar", "customizeMessages": "Customizar Emails", "userExpiryDescription": "Após um determinado período de tempo de cada inscrição, o jfa-go apagará/desabilitará a conta. Você pode alterar essa opção nas configurações.", "inviteDuration": "Duração do Convite", @@ -89,7 +88,6 @@ "sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.", "searchDiscordUser": "Digite o nome de usuário do Discord.", "findDiscordUser": "Encontrar usuário Discord", - "add": "Adicionar", "linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.", "select": "Selecionar", "templates": "Modelos", diff --git a/lang/admin/sv-se.json b/lang/admin/sv-se.json index f57d37e..60dd719 100644 --- a/lang/admin/sv-se.json +++ b/lang/admin/sv-se.json @@ -34,7 +34,6 @@ "variables": "Variabler", "preview": "Förhandsvisning", "reset": "Återställ", - "edit": "Redigera", "customizeMessages": "Anpassa e-post", "customizeMessagesDescription": "Om du inte vill använda jfa-go's e-postmallar, så kan du skapa dina egna med Markdown.", "markdownSupported": "Markdown stöds.", diff --git a/lang/admin/vi-vn.json b/lang/admin/vi-vn.json index cb058f4..6366e28 100644 --- a/lang/admin/vi-vn.json +++ b/lang/admin/vi-vn.json @@ -18,7 +18,6 @@ "create": "Tạo mới", "apply": "Áp dụng", "delete": "Xóa", - "add": "Thêm", "select": "Chọn", "name": "Tên", "date": "Ngày", @@ -48,7 +47,6 @@ "conditionals": "Điều kiện", "preview": "Xem trước", "reset": "Đặt lại", - "edit": "Chỉnh sửa", "donate": "Đóng góp", "sendPWR": "Gửi Đặt lại Mật khẩu", "contactThrough": "Liên lạc qua:", diff --git a/lang/admin/zh-hans.json b/lang/admin/zh-hans.json index 97561a2..98d050f 100644 --- a/lang/admin/zh-hans.json +++ b/lang/admin/zh-hans.json @@ -18,7 +18,6 @@ "create": "创建", "apply": "申请", "delete": "删除", - "add": "添加", "select": "选择", "name": "名称", "date": "日期", @@ -47,7 +46,6 @@ "conditionals": "条件性条款", "preview": "预览", "reset": "重设", - "edit": "编辑", "donate": "捐助", "contactThrough": "联系方式:", "extendExpiry": "延长有效期", diff --git a/lang/admin/zh-hant.json b/lang/admin/zh-hant.json index a4b318f..d876316 100644 --- a/lang/admin/zh-hant.json +++ b/lang/admin/zh-hant.json @@ -18,7 +18,6 @@ "create": "創建", "apply": "應用", "delete": "刪除", - "add": "添加", "select": "選擇", "name": "帳戶名稱", "date": "日期", @@ -49,7 +48,6 @@ "conditionals": "條件", "preview": "預覽", "reset": "重設", - "edit": "編輯", "donate": "捐贈", "sendPWR": "發送密碼重置", "contactThrough": "聯繫方式:", diff --git a/lang/common/da-dk.json b/lang/common/da-dk.json index 8155ca8..b2c3f3f 100644 --- a/lang/common/da-dk.json +++ b/lang/common/da-dk.json @@ -32,7 +32,9 @@ "disabled": "Deaktiveret", "reEnable": "Genaktiver", "disable": "Deaktiver", - "expiry": "Udløb" + "expiry": "Udløb", + "add": "Tilføj", + "edit": "Rediger" }, "notifications": { "errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.", diff --git a/lang/common/de-de.json b/lang/common/de-de.json index 9b10ccd..339ce88 100644 --- a/lang/common/de-de.json +++ b/lang/common/de-de.json @@ -32,7 +32,9 @@ "disabled": "Deaktiviert", "reEnable": "Wieder aktivieren", "disable": "Deaktivieren", - "expiry": "Ablaufdatum" + "expiry": "Ablaufdatum", + "add": "Hinzufügen", + "edit": "Bearbeiten" }, "notifications": { "errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.", diff --git a/lang/common/el-gr.json b/lang/common/el-gr.json index 1a1a0d6..7826ab7 100644 --- a/lang/common/el-gr.json +++ b/lang/common/el-gr.json @@ -23,7 +23,8 @@ "disabled": "Απενεργοποιημένο", "reEnable": "Επανα-ενεργοποίηση", "disable": "Απενεργοποίηση", - "expiry": "Λήξη" + "expiry": "Λήξη", + "edit": "Επεξεργασία" }, "notifications": { "errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.", diff --git a/lang/common/en-gb.json b/lang/common/en-gb.json index 799d4bf..bb44536 100644 --- a/lang/common/en-gb.json +++ b/lang/common/en-gb.json @@ -32,7 +32,9 @@ "disabled": "Disabled", "reEnable": "Re-enable", "disable": "Disable", - "expiry": "Expiry" + "expiry": "Expiry", + "add": "Add", + "edit": "Edit" }, "notifications": { "errorLoginBlank": "The username and/or password was left blank.", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index de53a29..3102ea3 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -33,9 +33,13 @@ "reEnable": "Re-enable", "disable": "Disable", "contactMethods": "Contact Methods", + "addContactMethod": "Add Contact Method", + "editContactMethod": "Edit Contact Method", "accountStatus": "Account Status", "notSet": "Not set", - "expiry": "Expiry" + "expiry": "Expiry", + "add": "Add", + "edit": "Edit" }, "notifications": { "errorLoginBlank": "The username and/or password were left blank.", @@ -58,4 +62,4 @@ "plural": "{n} Days" } } -} \ No newline at end of file +} diff --git a/lang/common/es-es.json b/lang/common/es-es.json index 261b22e..b446d47 100644 --- a/lang/common/es-es.json +++ b/lang/common/es-es.json @@ -32,7 +32,9 @@ "disabled": "Desactivado", "reEnable": "Reactivar", "disable": "Desactivar", - "expiry": "Expiración" + "expiry": "Expiración", + "add": "Agregar", + "edit": "Editar" }, "notifications": { "errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.", diff --git a/lang/common/fr-fr.json b/lang/common/fr-fr.json index 9a31d3d..193ef1f 100644 --- a/lang/common/fr-fr.json +++ b/lang/common/fr-fr.json @@ -32,7 +32,9 @@ "disabled": "Désactivé", "reEnable": "Ré-activé", "disable": "Désactivé", - "expiry": "Expiration" + "expiry": "Expiration", + "add": "Ajouter", + "edit": "Éditer" }, "notifications": { "errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.", diff --git a/lang/common/hu-hu.json b/lang/common/hu-hu.json index 367dcec..c4323a9 100644 --- a/lang/common/hu-hu.json +++ b/lang/common/hu-hu.json @@ -10,7 +10,9 @@ "disabled": "Tiltva", "reEnable": "Újra engedélyezés", "disable": "Letiltás", - "expiry": "Lejárat" + "expiry": "Lejárat", + "add": "Hozzáadás", + "edit": "Szerkesztés" }, "notifications": {}, "quantityStrings": {} diff --git a/lang/common/id-id.json b/lang/common/id-id.json index b517b8d..a925667 100644 --- a/lang/common/id-id.json +++ b/lang/common/id-id.json @@ -17,7 +17,8 @@ "time12h": "Waktu 12 jam", "theme": "Tema", "login": "Masuk", - "logout": "Keluar" + "logout": "Keluar", + "edit": "Edit" }, "notifications": { "errorLoginBlank": "Nama pengguna dan / atau sandi kosong.", diff --git a/lang/common/nl-nl.json b/lang/common/nl-nl.json index 9dbddee..b43329a 100644 --- a/lang/common/nl-nl.json +++ b/lang/common/nl-nl.json @@ -32,7 +32,9 @@ "disabled": "Uitgeschakeld", "reEnable": "Opnieuw inschakelen", "disable": "Uitschakelen", - "expiry": "Verloop" + "expiry": "Verloop", + "add": "Voeg toe", + "edit": "Bewerken" }, "notifications": { "errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.", diff --git a/lang/common/pl-pl.json b/lang/common/pl-pl.json index 8d042fa..6dbf2bc 100644 --- a/lang/common/pl-pl.json +++ b/lang/common/pl-pl.json @@ -29,7 +29,8 @@ "enabled": "Włączone", "disabled": "Wyłączone", "disable": "Wyłączone", - "expiry": "Wygasa" + "expiry": "Wygasa", + "edit": "Edytuj" }, "notifications": { "errorConnection": "Nie udało się połączyć z jfa-go.", diff --git a/lang/common/pt-br.json b/lang/common/pt-br.json index e4f6191..5ed9ae1 100644 --- a/lang/common/pt-br.json +++ b/lang/common/pt-br.json @@ -32,7 +32,9 @@ "disabled": "Desativado", "reEnable": "Reativar", "disable": "Desativar", - "expiry": "Expira" + "expiry": "Expira", + "add": "Adicionar", + "edit": "Editar" }, "notifications": { "errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.", diff --git a/lang/common/sv-se.json b/lang/common/sv-se.json index 25d5962..554ae2c 100644 --- a/lang/common/sv-se.json +++ b/lang/common/sv-se.json @@ -20,7 +20,8 @@ "admin": "Admin", "enabled": "Aktiverad", "disabled": "Inaktiverad", - "expiry": "Löper ut" + "expiry": "Löper ut", + "edit": "Redigera" }, "notifications": { "errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.", diff --git a/lang/common/vi-vn.json b/lang/common/vi-vn.json index 4464173..dce2ccb 100644 --- a/lang/common/vi-vn.json +++ b/lang/common/vi-vn.json @@ -10,7 +10,9 @@ "disabled": "Tắt", "reEnable": "Mở lại", "disable": "Tắt", - "expiry": "Hết hạn" + "expiry": "Hết hạn", + "add": "Thêm", + "edit": "Chỉnh sửa" }, "notifications": { "errorConnection": "Không thể kết nối với jfa-go.", diff --git a/lang/common/zh-hans.json b/lang/common/zh-hans.json index 17e7354..1c6daa7 100644 --- a/lang/common/zh-hans.json +++ b/lang/common/zh-hans.json @@ -32,7 +32,9 @@ "disabled": "已禁用", "reEnable": "重新启用", "disable": "禁用", - "expiry": "到期" + "expiry": "到期", + "add": "添加", + "edit": "编辑" }, "notifications": { "errorLoginBlank": "用户名/密码留空。", diff --git a/lang/common/zh-hant.json b/lang/common/zh-hant.json index f82182c..a4fe3a6 100644 --- a/lang/common/zh-hant.json +++ b/lang/common/zh-hant.json @@ -32,7 +32,9 @@ "disabled": "已禁用", "reEnable": "重新啟用", "disable": "禁用", - "expiry": "到期" + "expiry": "到期", + "add": "添加", + "edit": "編輯" }, "notifications": { "errorLoginBlank": "帳戶名稱和/或密碼留空。", diff --git a/models.go b/models.go index 136bcb0..084d010 100644 --- a/models.go +++ b/models.go @@ -391,3 +391,14 @@ type MyDetailsContactMethodsDTO struct { Value string `json:"value"` Enabled bool `json:"enabled"` } + +type ModifyMyEmailDTO struct { + Email string `json:"email"` +} + +type ConfirmationTarget int + +const ( + UserEmailChange ConfirmationTarget = iota + NoOp +) diff --git a/router.go b/router.go index 9e026e1..27e2281 100644 --- a/router.go +++ b/router.go @@ -147,6 +147,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.GET(p+"/my/account", app.MyUserPage) router.GET(p+"/my/token/login", app.getUserTokenLogin) router.GET(p+"/my/token/refresh", app.getUserTokenRefresh) + router.GET(p+"/my/confirm/:jwt", app.ConfirmMyAction) } } if *SWAGGER { @@ -229,6 +230,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.GET(p+"/details", app.MyDetails) user.POST(p+"/contact", app.SetMyContactMethods) user.POST(p+"/logout", app.LogoutUser) + user.POST(p+"/email", app.ModifyMyEmail) } } } diff --git a/scripts/langmover/common.json b/scripts/langmover/common.json index 76eb632..b04d430 100644 --- a/scripts/langmover/common.json +++ b/scripts/langmover/common.json @@ -35,7 +35,9 @@ "contactMethods": "common", "accountStatus": "common", "notSet": "common", - "expiry": "admin" + "expiry": "common", + "add": "admin", + "edit": "admin" }, "notifications": { "errorLoginBlank": "common", diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 4096fb9..4c174da 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -111,6 +111,7 @@ declare interface Modals { matrix: Modal; sendPWR?: Modal; logs: Modal; + email?: Modal; } interface Invite { diff --git a/ts/user.ts b/ts/user.ts index ca526e4..a611c15 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -1,7 +1,7 @@ import { ThemeManager } from "./modules/theme.js"; import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; -import { _get, _post, notificationBox, whichAnimationEvent, toDateString } from "./modules/common.js"; +import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; interface userWindow extends Window { @@ -25,6 +25,7 @@ window.modals = {} as Modals; (() => { window.modals.login = new Modal(document.getElementById("modal-login"), true); + window.modals.email = new Modal(document.getElementById("modal-email"), false); })(); window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5); @@ -73,12 +74,12 @@ class ContactMethods { this._buttons = {}; } - append = (name: string, details: MyDetailsContactMethod, icon: string) => { + append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void) => { const row = document.createElement("div"); row.classList.add("row", "flex-expand", "my-2"); - row.innerHTML = ` + let innerHTML = `
- + ${icon} @@ -86,12 +87,24 @@ class ContactMethods { ${(details.value == "") ? window.lang.strings("notSet") : details.value}
- + `; + if (addEditFunc) { + innerHTML += ` + + `; + } + innerHTML += `
`; + + row.innerHTML = innerHTML; this._buttons[name] = { element: row, @@ -102,11 +115,11 @@ class ContactMethods { const checkbox = button.querySelector("input[type=checkbox]") as HTMLInputElement; const setButtonAppearance = () => { if (checkbox.checked) { - button.classList.add("~info"); + button.classList.add("~urge"); button.classList.remove("~neutral"); } else { button.classList.add("~neutral"); - button.classList.remove("~info"); + button.classList.remove("~urge"); } }; const onPress = () => { @@ -124,6 +137,11 @@ class ContactMethods { checkbox.checked = details.enabled; setButtonAppearance(); + if (addEditFunc) { + const addEditButton = row.querySelector(".user-contact-edit") as HTMLButtonElement; + addEditButton.onclick = () => addEditFunc(details.value == ""); + } + this._content.appendChild(row); }; @@ -220,6 +238,36 @@ var expiryCard = new ExpiryCard(statusCard); var contactMethodList = new ContactMethods(contactCard); +const addEditEmail = (add: boolean): void => { + console.log("call"); + const heading = window.modals.email.modal.querySelector(".heading"); + heading.innerHTML = (add ? window.lang.strings("addContactMethod") : window.lang.strings("editContactMethod")) + `×`; + const input = document.getElementById("modal-email-input") as HTMLInputElement; + input.value = ""; + const confirmationRequired = window.modals.email.modal.querySelector(".confirmation-required"); + confirmationRequired.classList.add("unfocused"); + + const content = window.modals.email.modal.querySelector(".content"); + content.classList.remove("unfocused"); + + const submit = window.modals.email.modal.querySelector(".modal-submit") as HTMLButtonElement; + submit.onclick = () => { + toggleLoader(submit); + _post("/my/email", {"email": input.value}, (req: XMLHttpRequest) => { + if (req.readyState == 4 && (req.status == 303 || req.status == 200)) { + window.location.reload(); + } + }, true, (req: XMLHttpRequest) => { + if (req.readyState == 4 && req.status == 401) { + content.classList.add("unfocused"); + confirmationRequired.classList.remove("unfocused"); + } + }); + } + + window.modals.email.show(); +} + document.addEventListener("details-reload", () => { _get("/my/details", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { @@ -244,16 +292,16 @@ document.addEventListener("details-reload", () => { contactMethodList.clear(); - const contactMethods = [ - ["email", ``], - ["discord", ``], - ["telegram", ``], - ["matrix", `[m]`] + const contactMethods: { name: string, icon: string, f: (add: boolean) => void }[] = [ + {name: "email", icon: ``, f: addEditEmail}, + {name: "discord", icon: ``, f: null}, + {name: "telegram", icon: ``, f: null}, + {name: "matrix", icon: `[m]`, f: null} ]; for (let method of contactMethods) { - if (method[0] in details) { - contactMethodList.append(method[0], details[method[0]], method[1]); + if (method.name in details) { + contactMethodList.append(method.name, details[method.name], method.icon, method.f); } } From cf7983ca11645bd91948b9619cfacccedb1aaebe Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 18 Jun 2023 21:38:12 +0100 Subject: [PATCH 18/54] userpage: add/edit discord works identically to on the form, would like to eventually factor out the discord/telegram/matrix verif stuff so it can be shared between the two pages though. --- api-userpage.go | 78 +++++++++++++++++++++++++++++++++++++++- html/user.html | 57 +++++++++++++++++++++++++++--- lang/form/en-us.json | 2 +- models.go | 4 +++ router.go | 3 ++ ts/form.ts | 7 +--- ts/modules/common.ts | 5 +++ ts/user.ts | 84 +++++++++++++++++++++++++++++++++++++++++--- views.go | 25 +++++++++++-- 9 files changed, 246 insertions(+), 19 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index dbfd0d5..c771b73 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -207,7 +207,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { // @Failure 500 {object} stringResponse // @Router /my/email [post] // @Security Bearer -// @tags Users +// @tags User Page func (app *appContext) ModifyMyEmail(gc *gin.Context) { var req ModifyMyEmailDTO gc.BindJSON(&req) @@ -258,3 +258,79 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) { app.confirmMyAction(gc, key) return } + +// @Summary Returns a 10-minute, one-use Discord server invite +// @Produce json +// @Success 200 {object} DiscordInviteDTO +// @Failure 400 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param invCode path string true "invite Code" +// @Router /my/discord/invite [get] +// @tags User Page +func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { + if app.discord.inviteChannelName == "" { + respondBool(400, false, gc) + return + } + invURL, iconURL := app.discord.NewTempInvite(10*60, 1) + if invURL == "" { + respondBool(500, false, gc) + return + } + gc.JSON(200, DiscordInviteDTO{invURL, iconURL}) +} + +// @Summary Returns a linking PIN for discord/telegram +// @Produce json +// @Success 200 {object} GetMyPINDTO +// @Failure 400 {object} stringResponse +// Param service path string true "discord/telegram" +// @Router /my/pin/{service} [get] +// @tags User Page +func (app *appContext) GetMyPIN(gc *gin.Context) { + service := gc.Param("service") + resp := GetMyPINDTO{} + switch service { + case "discord": + resp.PIN = app.discord.NewAuthToken() + break + case "telegram": + resp.PIN = app.telegram.NewAuthToken() + break + default: + respond(400, "invalid service", gc) + return + } + gc.JSON(200, resp) +} + +// @Summary Returns true/false on whether or not your discord PIN was verified, and assigns the discord user to you. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Param pin path string true "PIN code to check" +// @Router /my/discord/verified/{pin} [get] +// @tags User Page +func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { + pin := gc.Param("pin") + dcUser, ok := app.discord.verifiedTokens[pin] + if !ok { + respondBool(200, false, gc) + return + } + if app.config.Section("discord").Key("require_unique").MustBool(false) { + for _, u := range app.storage.discord { + if app.discord.verifiedTokens[pin].ID == u.ID { + delete(app.discord.verifiedTokens, pin) + respondBool(400, false, gc) + return + } + } + } + dc := app.storage.discord + dc[gc.GetString("jfId")] = dcUser + app.storage.discord = dc + app.storage.storeDiscordUsers() + respondBool(200, true, gc) +} diff --git a/html/user.html b/html/user.html index 39789cd..3e61003 100644 --- a/html/user.html +++ b/html/user.html @@ -4,14 +4,22 @@ {{ template "header.html" . }} {{ .lang.Strings.pageTitle }} @@ -33,6 +41,47 @@ + + +
diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 43b2928..715d0d3 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -60,4 +60,4 @@ "plural": "Must have at least {n} special characters" } } -} \ No newline at end of file +} diff --git a/models.go b/models.go index 084d010..9f3aef9 100644 --- a/models.go +++ b/models.go @@ -402,3 +402,7 @@ const ( UserEmailChange ConfirmationTarget = iota NoOp ) + +type GetMyPINDTO struct { + PIN string `json:"pin"` +} diff --git a/router.go b/router.go index 27e2281..80bec1a 100644 --- a/router.go +++ b/router.go @@ -231,6 +231,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.POST(p+"/contact", app.SetMyContactMethods) user.POST(p+"/logout", app.LogoutUser) user.POST(p+"/email", app.ModifyMyEmail) + user.GET(p+"/discord/invite", app.MyDiscordServerInvite) + user.GET(p+"/pin/:service", app.GetMyPIN) + user.GET(p+"/discord/verified/:pin", app.MyDiscordVerifiedInvite) } } } diff --git a/ts/form.ts b/ts/form.ts index 31cdf0d..b3dbc25 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -1,6 +1,6 @@ import { Modal } from "./modules/modal.js"; import { notificationBox, whichAnimationEvent } from "./modules/common.js"; -import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; +import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString, DiscordInvite } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; import { initValidator } from "./modules/validator.js"; @@ -91,11 +91,6 @@ if (window.telegramEnabled) { }; } -interface DiscordInvite { - invite: string; - icon: string; -} - var discordVerified = false; if (window.discordEnabled) { window.discordModal = new Modal(document.getElementById("modal-discord"), window.discordRequired); diff --git a/ts/modules/common.ts b/ts/modules/common.ts index 4687cc8..6ae320d 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -221,3 +221,8 @@ export function insertText(textarea: HTMLTextAreaElement, text: string) { textarea.focus(); } } + +export interface DiscordInvite { + invite: string; + icon: string; +} diff --git a/ts/user.ts b/ts/user.ts index a611c15..ac34517 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -1,12 +1,20 @@ import { ThemeManager } from "./modules/theme.js"; import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; -import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; +import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader, DiscordInvite } from "./modules/common.js"; import { Login } from "./modules/login.js"; interface userWindow extends Window { jellyfinID: string; username: string; + emailRequired: boolean; + discordRequired: boolean; + telegramRequired: boolean; + matrixRequired: boolean; + discordServerName: string; + discordInviteLink: boolean; + matrixUserID: string; + discordSendPINMessage: string; } declare var window: userWindow; @@ -26,6 +34,9 @@ window.modals = {} as Modals; (() => { window.modals.login = new Modal(document.getElementById("modal-login"), true); window.modals.email = new Modal(document.getElementById("modal-email"), false); + window.modals.discord = new Modal(document.getElementById("modal-discord"), false); + window.modals.telegram = new Modal(document.getElementById("modal-telegram"), false); + window.modals.matrix = new Modal(document.getElementById("modal-matrix"), false); })(); window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5); @@ -87,8 +98,8 @@ class ContactMethods { ${(details.value == "") ? window.lang.strings("notSet") : details.value}
- `; @@ -239,7 +250,6 @@ var expiryCard = new ExpiryCard(statusCard); var contactMethodList = new ContactMethods(contactCard); const addEditEmail = (add: boolean): void => { - console.log("call"); const heading = window.modals.email.modal.querySelector(".heading"); heading.innerHTML = (add ? window.lang.strings("addContactMethod") : window.lang.strings("editContactMethod")) + `×`; const input = document.getElementById("modal-email-input") as HTMLInputElement; @@ -268,6 +278,70 @@ const addEditEmail = (add: boolean): void => { window.modals.email.show(); } +let discordModalClosed = false; +let discordPIN = ""; +const addEditDiscord = (add: boolean): void => { + if (window.discordInviteLink) { + _get("/my/discord/invite", null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) return; + const inv = req.response as DiscordInvite; + const link = document.getElementById("discord-invite") as HTMLAnchorElement; + link.href = inv.invite; + link.target = "_blank"; + link.innerHTML = `${window.discordServerName}`; + } + }); + } + + _get("/my/pin/discord", null, (req: XMLHttpRequest) => { + if (req.readyState == 4 && req.status == 200) { + discordPIN = req.response["pin"]; + window.modals.discord.modal.querySelector(".pin").textContent = discordPIN; + } + }); + + const waiting = document.getElementById("discord-waiting") as HTMLSpanElement; + toggleLoader(waiting); + window.modals.discord.show(); + discordModalClosed = false; + window.modals.discord.onclose = () => { + discordModalClosed = true; + toggleLoader(waiting); + } + const checkVerified = () => { + if (discordPIN == "") { + setTimeout(checkVerified, 1500); + } + if (discordModalClosed) return; + _get("/my/discord/verified/" + discordPIN, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status == 401) { + window.modals.discord.close(); + window.notifications.customError("invalidCodeError", window.lang.notif("errorInvalidCode")); + return; + } else if (req.status == 400) { + window.modals.discord.close(); + window.notifications.customError("accountLinkedError", window.lang.notif("errorAccountLinked")); + } else if (req.status == 200) { + if (req.response["success"] as boolean) { + waiting.classList.add("~positive"); + waiting.classList.remove("~info"); + window.notifications.customPositive("discordVerified", "", window.lang.notif("verified")); + setTimeout(() => { + window.modals.discord.close; + window.location.reload(); + }, 2000); + } else if (!discordModalClosed) { + setTimeout(checkVerified, 1500); + } + } + }); + }; + + checkVerified(); +}; + document.addEventListener("details-reload", () => { _get("/my/details", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { @@ -294,7 +368,7 @@ document.addEventListener("details-reload", () => { const contactMethods: { name: string, icon: string, f: (add: boolean) => void }[] = [ {name: "email", icon: ``, f: addEditEmail}, - {name: "discord", icon: ``, f: null}, + {name: "discord", icon: ``, f: addEditDiscord}, {name: "telegram", icon: ``, f: null}, {name: "matrix", icon: `[m]`, f: null} ]; diff --git a/views.go b/views.go index a158766..ebb855b 100644 --- a/views.go +++ b/views.go @@ -165,12 +165,13 @@ func (app *appContext) MyUserPage(gc *gin.Context) { emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool() notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool() ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) - gcHTML(gc, http.StatusOK, "user.html", gin.H{ + data := gin.H{ "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), "emailEnabled": emailEnabled, + "emailRequired": app.config.Section("email").Key("required").MustBool(false), "telegramEnabled": telegramEnabled, "discordEnabled": discordEnabled, "matrixEnabled": matrixEnabled, @@ -182,7 +183,27 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "validationStrings": app.storage.lang.User[lang].ValidationStrings, "language": app.storage.lang.User[lang].JSON, "langName": lang, - }) + } + if telegramEnabled { + data["telegramUser"] = app.telegram.username + data["telegramURL"] = app.telegram.link + data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false) + } + if matrixEnabled { + data["matrixRequired"] = app.config.Section("matrix").Key("required").MustBool(false) + data["matrixUser"] = app.matrix.userID + } + if discordEnabled { + data["discordUsername"] = app.discord.username + data["discordRequired"] = app.config.Section("discord").Key("required").MustBool(false) + data["discordSendPINMessage"] = template.HTML(app.storage.lang.User[lang].Strings.template("sendPINDiscord", tmpl{ + "command": `/` + app.config.Section("discord").Key("start_command").MustString("start") + ``, + "server_channel": app.discord.serverChannelName, + })) + data["discordServerName"] = app.discord.serverName + data["discordInviteLink"] = app.discord.inviteChannelName != "" + } + gcHTML(gc, http.StatusOK, "user.html", data) } func (app *appContext) ResetPassword(gc *gin.Context) { From 765a749959f117b2d87c609a29dab561f5d2e381 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 19 Jun 2023 11:58:09 +0100 Subject: [PATCH 19/54] discord: modularize user-facing code will be done for others too, code for discord account linking in form and userpage is now in ts/modules/account-linking.ts as a configurable class. --- ts/form.ts | 78 ++++++------------ ts/modules/account-linking.ts | 144 ++++++++++++++++++++++++++++++++++ ts/modules/common.ts | 5 -- ts/user.ts | 79 ++++--------------- 4 files changed, 185 insertions(+), 121 deletions(-) create mode 100644 ts/modules/account-linking.ts diff --git a/ts/form.ts b/ts/form.ts index b3dbc25..01b8d87 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -1,8 +1,9 @@ import { Modal } from "./modules/modal.js"; import { notificationBox, whichAnimationEvent } from "./modules/common.js"; -import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString, DiscordInvite } from "./modules/common.js"; +import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; import { initValidator } from "./modules/validator.js"; +import { Discord, DiscordConfiguration } from "./modules/account-linking.js"; interface formWindow extends Window { invalidPassword: string; @@ -95,61 +96,32 @@ var discordVerified = false; if (window.discordEnabled) { window.discordModal = new Modal(document.getElementById("modal-discord"), window.discordRequired); const discordButton = document.getElementById("link-discord") as HTMLSpanElement; - if (window.discordInviteLink) { - _get("/invite/" + window.code + "/discord/invite", null, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - if (req.status != 200) { - return; - } - const inv = req.response as DiscordInvite; - const link = document.getElementById("discord-invite") as HTMLAnchorElement; - link.classList.add("subheading", "link-center"); - link.href = inv.invite; - link.target = "_blank"; - link.innerHTML = `${window.discordServerName}`; + + const discordConf: DiscordConfiguration = { + modal: window.discordModal as Modal, + pin: window.discordPIN, + inviteURL: window.discordInviteLink ? ("/invite/" + window.code + "/discord/invite") : "", + pinURL: "", + verifiedURL: "/invite/" + window.code + "/discord/verified/", + invalidCodeError: window.messages["errorInvalidCode"], + accountLinkedError: window.messages["errorAccountLinked"], + successError: window.messages["verified"], + successFunc: (modalClosed: boolean) => { + if (!modalClosed) { + discordButton.classList.add("unfocused"); + document.getElementById("contact-via").classList.remove("unfocused"); + document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); + const radio = document.getElementById("contact-via-discord") as HTMLInputElement; + radio.parentElement.classList.remove("unfocused") + radio.checked = true; + validatorFunc(); } - }); - } - discordButton.onclick = () => { - const waiting = document.getElementById("discord-waiting") as HTMLSpanElement; - toggleLoader(waiting); - window.discordModal.show(); - let modalClosed = false; - window.discordModal.onclose = () => { - modalClosed = true; - toggleLoader(waiting); } - const checkVerified = () => _get("/invite/" + window.code + "/discord/verified/" + window.discordPIN, null, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - if (req.status == 401) { - window.discordModal.close(); - window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]); - return; - } else if (req.status == 400) { - window.discordModal.close(); - window.notifications.customError("accountLinkedError", window.messages["errorAccountLinked"]); - } else if (req.status == 200) { - if (req.response["success"] as boolean) { - discordVerified = true; - waiting.classList.add("~positive"); - waiting.classList.remove("~info"); - window.notifications.customPositive("discordVerified", "", window.messages["verified"]); - setTimeout(window.discordModal.close, 2000); - discordButton.classList.add("unfocused"); - document.getElementById("contact-via").classList.remove("unfocused"); - document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); - const radio = document.getElementById("contact-via-discord") as HTMLInputElement; - radio.parentElement.classList.remove("unfocused") - radio.checked = true; - validatorFunc(); - } else if (!modalClosed) { - setTimeout(checkVerified, 1500); - } - } - } - }); - checkVerified(); }; + + const discord = new Discord(discordConf); + + discordButton.onclick = discord.onclick; } var matrixVerified = false; diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts new file mode 100644 index 0000000..fce2ec8 --- /dev/null +++ b/ts/modules/account-linking.ts @@ -0,0 +1,144 @@ +import { Modal } from "../modules/modal.js"; +import { _get, _post, toggleLoader } from "../modules/common.js"; + +interface formWindow extends Window { + invalidPassword: string; + successModal: Modal; + telegramModal: Modal; + discordModal: Modal; + matrixModal: Modal; + confirmationModal: Modal; + redirectToJellyfin: boolean; + code: string; + messages: { [key: string]: string }; + confirmation: boolean; + telegramRequired: boolean; + telegramPIN: string; + discordRequired: boolean; + discordPIN: string; + discordStartCommand: string; + discordInviteLink: boolean; + discordServerName: string; + matrixRequired: boolean; + matrixUserID: string; + userExpiryEnabled: boolean; + userExpiryMonths: number; + userExpiryDays: number; + userExpiryHours: number; + userExpiryMinutes: number; + userExpiryMessage: string; + emailRequired: boolean; + captcha: boolean; + reCAPTCHA: boolean; + reCAPTCHASiteKey: string; +} + +declare var window: formWindow; + +export interface DiscordConfiguration { + modal: Modal; + pin: string; + inviteURL: string; + pinURL: string; + verifiedURL: string; + invalidCodeError: string; + accountLinkedError: string; + successError: string; + successFunc: (modalClosed: boolean) => void; +}; + +export interface DiscordInvite { + invite: string; + icon: string; +} + +export class Discord { + private _conf: DiscordConfiguration; + private _pinAcquired = false; + private _modalClosed = false; + private _waiting = document.getElementById("discord-waiting") as HTMLSpanElement; + private _verified = false; + + get verified(): boolean { return this._verified; } + + constructor(conf: DiscordConfiguration) { + this._conf = conf; + this._conf.modal.onclose = () => { + this._modalClosed = true; + toggleLoader(this._waiting); + }; + } + + private _getInviteURL = () => _get(this._conf.inviteURL, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + const inv = req.response as DiscordInvite; + const link = document.getElementById("discord-invite") as HTMLAnchorElement; + link.href = inv.invite; + link.target = "_blank"; + link.innerHTML = `${window.discordServerName}`; + }); + + private _checkVerified = () => { + if (this._modalClosed) return; + if (!this._pinAcquired) { + setTimeout(this._checkVerified, 1500); + return; + } + _get(this._conf.verifiedURL + this._conf.pin, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status == 401) { + this._conf.modal.close(); + window.notifications.customError("invalidCodeError", this._conf.invalidCodeError); + } else if (req.status == 400) { + this._conf.modal.close(); + window.notifications.customError("accountLinkedError", this._conf.accountLinkedError); + } else if (req.status == 200) { + if (req.response["success"] as boolean) { + this._verified = true; + this._waiting.classList.add("~positive"); + this._waiting.classList.remove("~info"); + window.notifications.customPositive("discordVerified", "", this._conf.successError); + if (this._conf.successFunc) { + this._conf.successFunc(false); + } + setTimeout(() => { + this._conf.modal.close(); + if (this._conf.successFunc) { + this._conf.successFunc(true); + } + }, 2000); + + } else if (!this._modalClosed) { + setTimeout(this._checkVerified, 1500); + } + } + }); + }; + + onclick = () => { + if (this._conf.inviteURL != "") { + this._getInviteURL(); + } + + toggleLoader(this._waiting); + + this._pinAcquired = false; + if (this._conf.pin) { + this._pinAcquired = true; + this._conf.modal.modal.querySelector(".pin").textContent = this._conf.pin; + } else { + _get(this._conf.pinURL, null, (req: XMLHttpRequest) => { + if (req.readyState == 4 && req.status == 200) { + this._conf.pin = req.response["pin"]; + this._conf.modal.modal.querySelector(".pin").textContent = this._conf.pin; + this._pinAcquired = true; + } + }); + } + + this._modalClosed = false; + this._conf.modal.show(); + + this._checkVerified(); + } +} diff --git a/ts/modules/common.ts b/ts/modules/common.ts index 6ae320d..4687cc8 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -221,8 +221,3 @@ export function insertText(textarea: HTMLTextAreaElement, text: string) { textarea.focus(); } } - -export interface DiscordInvite { - invite: string; - icon: string; -} diff --git a/ts/user.ts b/ts/user.ts index ac34517..465dfd9 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -1,8 +1,9 @@ import { ThemeManager } from "./modules/theme.js"; import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; -import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader, DiscordInvite } from "./modules/common.js"; +import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; +import { Discord, DiscordConfiguration } from "./modules/account-linking.js"; interface userWindow extends Window { jellyfinID: string; @@ -278,70 +279,22 @@ const addEditEmail = (add: boolean): void => { window.modals.email.show(); } -let discordModalClosed = false; -let discordPIN = ""; -const addEditDiscord = (add: boolean): void => { - if (window.discordInviteLink) { - _get("/my/discord/invite", null, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - if (req.status != 200) return; - const inv = req.response as DiscordInvite; - const link = document.getElementById("discord-invite") as HTMLAnchorElement; - link.href = inv.invite; - link.target = "_blank"; - link.innerHTML = `${window.discordServerName}`; - } - }); +const discordConf: DiscordConfiguration = { + modal: window.modals.discord as Modal, + pin: "", + inviteURL: window.discordInviteLink ? "/my/discord/invite" : "", + pinURL: "/my/pin/discord", + verifiedURL: "/my/discord/verified/", + invalidCodeError: window.lang.notif("errorInvalidCode"), + accountLinkedError: window.lang.notif("errorAccountLinked"), + successError: window.lang.notif("verified"), + successFunc: (modalClosed: boolean) => { + if (modalClosed) window.location.reload(); } - - _get("/my/pin/discord", null, (req: XMLHttpRequest) => { - if (req.readyState == 4 && req.status == 200) { - discordPIN = req.response["pin"]; - window.modals.discord.modal.querySelector(".pin").textContent = discordPIN; - } - }); - - const waiting = document.getElementById("discord-waiting") as HTMLSpanElement; - toggleLoader(waiting); - window.modals.discord.show(); - discordModalClosed = false; - window.modals.discord.onclose = () => { - discordModalClosed = true; - toggleLoader(waiting); - } - const checkVerified = () => { - if (discordPIN == "") { - setTimeout(checkVerified, 1500); - } - if (discordModalClosed) return; - _get("/my/discord/verified/" + discordPIN, null, (req: XMLHttpRequest) => { - if (req.readyState != 4) return; - if (req.status == 401) { - window.modals.discord.close(); - window.notifications.customError("invalidCodeError", window.lang.notif("errorInvalidCode")); - return; - } else if (req.status == 400) { - window.modals.discord.close(); - window.notifications.customError("accountLinkedError", window.lang.notif("errorAccountLinked")); - } else if (req.status == 200) { - if (req.response["success"] as boolean) { - waiting.classList.add("~positive"); - waiting.classList.remove("~info"); - window.notifications.customPositive("discordVerified", "", window.lang.notif("verified")); - setTimeout(() => { - window.modals.discord.close; - window.location.reload(); - }, 2000); - } else if (!discordModalClosed) { - setTimeout(checkVerified, 1500); - } - } - }); - }; - - checkVerified(); }; +let discord = new Discord(discordConf); + document.addEventListener("details-reload", () => { _get("/my/details", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { @@ -368,7 +321,7 @@ document.addEventListener("details-reload", () => { const contactMethods: { name: string, icon: string, f: (add: boolean) => void }[] = [ {name: "email", icon: ``, f: addEditEmail}, - {name: "discord", icon: ``, f: addEditDiscord}, + {name: "discord", icon: ``, f: discord.onclick}, {name: "telegram", icon: ``, f: null}, {name: "matrix", icon: `[m]`, f: null} ]; From 094f7cea9445bafa2c342772912c9ec04f6fd8e6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 19 Jun 2023 17:48:24 +0100 Subject: [PATCH 20/54] discord: use placeholder if guild icon not available also centers the invite on the form/user discord modal. --- discord.go | 2 ++ html/form.html | 2 +- html/user.html | 4 +++- tailwind.config.js | 3 ++- ts/modules/account-linking.ts | 8 +++++++- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/discord.go b/discord.go index 1f37a03..f10e7c5 100644 --- a/discord.go +++ b/discord.go @@ -210,7 +210,9 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU d.app.err.Printf("Discord: Failed to get guild: %v", err) return } + // FIXME: Fix CSS, and handle no icon iconURL = guild.IconURL("256") + fmt.Println("GOT ICON", iconURL) return } diff --git a/html/form.html b/html/form.html index dff83e5..1ef1820 100644 --- a/html/form.html +++ b/html/form.html @@ -49,7 +49,7 @@
{{ .strings.linkDiscord }}

{{ .discordSendPINMessage }}

-

{{ .discordPIN }}

+

{{ .strings.success }}
diff --git a/html/user.html b/html/user.html index 3e61003..3de47a7 100644 --- a/html/user.html +++ b/html/user.html @@ -46,7 +46,9 @@ {{ .strings.linkDiscord }}

{{ .discordSendPINMessage }}

- +
+ +
{{ .strings.success }}
diff --git a/tailwind.config.js b/tailwind.config.js index e6c93da..b9825a6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -40,7 +40,8 @@ module.exports = { d_urge: dark.d_urge, d_warning: dark.d_warning, d_info: dark.d_info, - d_critical: dark.d_critical + d_critical: dark.d_critical, + discord: "#5865F2" } } }, diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index fce2ec8..76e34c5 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -75,7 +75,13 @@ export class Discord { const link = document.getElementById("discord-invite") as HTMLAnchorElement; link.href = inv.invite; link.target = "_blank"; - link.innerHTML = `${window.discordServerName}`; + if (inv.icon != "") { + link.innerHTML = `${window.discordServerName}`; + } else { + link.innerHTML = ` + ${window.discordServerName} + `; + } }); private _checkVerified = () => { From 68aedf07ae0d978ab89a015dff4a8088fe40d346 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 19 Jun 2023 18:03:35 +0100 Subject: [PATCH 21/54] discord: pad, underline invite link --- html/account-linking.html | 49 +++++++++++++++++++++++++++++++++++ html/form.html | 48 +--------------------------------- html/user.html | 44 +------------------------------ lang/form/en-us.json | 3 ++- ts/modules/account-linking.ts | 6 +++-- ts/user.ts | 12 ++++++--- 6 files changed, 66 insertions(+), 96 deletions(-) create mode 100644 html/account-linking.html diff --git a/html/account-linking.html b/html/account-linking.html new file mode 100644 index 0000000..0fcaf66 --- /dev/null +++ b/html/account-linking.html @@ -0,0 +1,49 @@ +{{ if .discordEnabled }} + +{{ end }} +{{ if .telegramEnabled }} + +{{ end }} +{{ if .matrixEnabled }} + +{{ end }} diff --git a/html/form.html b/html/form.html index 1ef1820..ef2b888 100644 --- a/html/form.html +++ b/html/form.html @@ -26,53 +26,7 @@

{{ .strings.confirmationRequiredMessage }}

- {{ if .telegramEnabled }} - - {{ end }} - {{ if .discordEnabled }} - - {{ end }} - {{ if .matrixEnabled }} - - {{ end }} + {{ template "account-linking.html" . }}
diff --git a/html/user.html b/html/user.html index 3de47a7..5135c2e 100644 --- a/html/user.html +++ b/html/user.html @@ -41,49 +41,7 @@
- - - + {{ template "account-linking.html" . }}
diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 715d0d3..1e132b3 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -20,7 +20,8 @@ "sendPIN": "Send the PIN below to the bot, then come back here to link your account.", "sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below.", "matrixEnterUser": "Enter your User ID, press submit, and a PIN will be sent to you. Enter it here to continue.", - "welcomeUser": "Welcome, {user}!" + "welcomeUser": "Welcome, {user}!", + "joinTheServer": "Join the server:" }, "notifications": { "errorUserExists": "User already exists.", diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index 76e34c5..5902732 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -75,13 +75,15 @@ export class Discord { const link = document.getElementById("discord-invite") as HTMLAnchorElement; link.href = inv.invite; link.target = "_blank"; + let innerHTML = `${window.lang.strings("joinTheServer")}`; if (inv.icon != "") { - link.innerHTML = `${window.discordServerName}`; + innerHTML += `${window.discordServerName}`; } else { - link.innerHTML = ` + innerHTML += ` ${window.discordServerName} `; } + link.innerHTML = innerHTML; }); private _checkVerified = () => { diff --git a/ts/user.ts b/ts/user.ts index 465dfd9..340f7e5 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -35,9 +35,15 @@ window.modals = {} as Modals; (() => { window.modals.login = new Modal(document.getElementById("modal-login"), true); window.modals.email = new Modal(document.getElementById("modal-email"), false); - window.modals.discord = new Modal(document.getElementById("modal-discord"), false); - window.modals.telegram = new Modal(document.getElementById("modal-telegram"), false); - window.modals.matrix = new Modal(document.getElementById("modal-matrix"), false); + if (window.discordEnabled) { + window.modals.discord = new Modal(document.getElementById("modal-discord"), false); + } + if (window.telegramEnabled) { + window.modals.telegram = new Modal(document.getElementById("modal-telegram"), false); + } + if (window.matrixEnabled) { + window.modals.matrix = new Modal(document.getElementById("modal-matrix"), false); + } })(); window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5); From fcedea110dd137a13e9629038d58de3e3f4a58dd Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 19 Jun 2023 22:11:35 +0100 Subject: [PATCH 22/54] telegram: modularize, add to userpage --- .goreleaser.yml | 10 ++-- api-userpage.go | 50 +++++++++++++++++++ html/account-linking.html | 7 ++- html/user.html | 2 + router.go | 1 + ts/form.ts | 85 ++++++++++++++------------------- ts/modules/account-linking.ts | 90 ++++++++++++++++++++++------------- ts/tsconfig.json | 4 +- ts/user.ts | 26 ++++++++-- views.go | 2 +- 10 files changed, 179 insertions(+), 98 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 88c2404..b15cb59 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -26,11 +26,11 @@ before: - cp -r ts tempts - scripts/dark-variant.sh tempts - scripts/dark-variant.sh tempts/modules - - npx esbuild --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify - - npx esbuild --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify - - npx esbuild --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify - - npx esbuild --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify - - npx esbuild --bundle tempts/crash.ts --outfile=./data/crash.js --minify + - npx esbuild --target=es6 --format=esm --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify + - npx esbuild --target=es6 --format=esm --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify + - npx esbuild --target=es6 --format=esm --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify + - npx esbuild --target=es6 --format=esm --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify + - npx esbuild --target=es6 --format=esm --bundle tempts/crash.ts --outfile=./data/crash.js --minify - rm -r tempts - npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify - cp html/crash.html data/ diff --git a/api-userpage.go b/api-userpage.go index c771b73..8c3f288 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -329,8 +329,58 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { } } dc := app.storage.discord + existingUser, ok := app.storage.discord[gc.GetString("jfId")] + if ok { + dcUser.Lang = existingUser.Lang + dcUser.Contact = existingUser.Contact + } dc[gc.GetString("jfId")] = dcUser app.storage.discord = dc app.storage.storeDiscordUsers() respondBool(200, true, gc) } + +// @Summary Returns true/false on whether or not your telegram PIN was verified, and assigns the telegram user to you. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Param pin path string true "PIN code to check" +// @Router /my/telegram/verified/{pin} [get] +// @tags User Page +func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { + pin := gc.Param("pin") + tokenIndex := -1 + for i, v := range app.telegram.verifiedTokens { + if v.Token == pin { + tokenIndex = i + break + } + } + if tokenIndex == -1 { + respondBool(200, false, gc) + return + } + if app.config.Section("telegram").Key("require_unique").MustBool(false) { + for _, u := range app.storage.telegram { + if app.telegram.verifiedTokens[tokenIndex].Username == u.Username { + respondBool(400, false, gc) + return + } + } + } + tgUser := TelegramUser{ + ChatID: app.telegram.verifiedTokens[tokenIndex].ChatID, + Username: app.telegram.verifiedTokens[tokenIndex].Username, + Contact: true, + } + + tg := app.storage.telegram + existingUser, ok := app.storage.telegram[gc.GetString("jfId")] + if ok { + tgUser.Lang = existingUser.Lang + tgUser.Contact = existingUser.Contact + } + tg[gc.GetString("jfId")] = tgUser + app.storage.storeTelegramUsers() + respondBool(200, true, gc) +} diff --git a/html/account-linking.html b/html/account-linking.html index 0fcaf66..e38f013 100644 --- a/html/account-linking.html +++ b/html/account-linking.html @@ -5,7 +5,10 @@

{{ .discordSendPINMessage }}

{{ .strings.success }}
@@ -16,7 +19,7 @@
{{ .strings.linkTelegram }}

{{ .strings.sendPIN }}

-

{{ .telegramPIN }}

+

diff --git a/html/user.html b/html/user.html index 5135c2e..609f7c0 100644 --- a/html/user.html +++ b/html/user.html @@ -10,6 +10,8 @@ window.language = "{{ .langName }}"; window.telegramEnabled = {{ .telegramEnabled }}; window.telegramRequired = {{ .telegramRequired }}; + window.telegramUsername = {{ .telegramUsername }}; + window.telegramURL = {{ .telegramURL }}; window.emailEnabled = {{ .emailEnabled }}; window.emailRequired = {{ .emailRequired }}; window.discordEnabled = {{ .discordEnabled }}; diff --git a/router.go b/router.go index 80bec1a..b1b5b95 100644 --- a/router.go +++ b/router.go @@ -234,6 +234,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.GET(p+"/discord/invite", app.MyDiscordServerInvite) user.GET(p+"/pin/:service", app.GetMyPIN) user.GET(p+"/discord/verified/:pin", app.MyDiscordVerifiedInvite) + user.GET(p+"/telegram/verified/:pin", app.MyTelegramVerifiedInvite) } } } diff --git a/ts/form.ts b/ts/form.ts index 01b8d87..59f4de2 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -3,7 +3,7 @@ import { notificationBox, whichAnimationEvent } from "./modules/common.js"; import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; import { initValidator } from "./modules/validator.js"; -import { Discord, DiscordConfiguration } from "./modules/account-linking.js"; +import { Discord, Telegram, ServiceConfiguration } from "./modules/account-linking.js"; interface formWindow extends Window { invalidPassword: string; @@ -50,46 +50,31 @@ var telegramVerified = false; if (window.telegramEnabled) { window.telegramModal = new Modal(document.getElementById("modal-telegram"), window.telegramRequired); const telegramButton = document.getElementById("link-telegram") as HTMLSpanElement; - telegramButton.onclick = () => { - const waiting = document.getElementById("telegram-waiting") as HTMLSpanElement; - toggleLoader(waiting); - window.telegramModal.show(); - let modalClosed = false; - window.telegramModal.onclose = () => { - modalClosed = true; - toggleLoader(waiting); + + const telegramConf: ServiceConfiguration = { + modal: window.telegramModal as Modal, + pin: window.telegramPIN, + pinURL: "", + verifiedURL: "/invite/" + window.code + "/telegram/verified/", + invalidCodeError: window.messages["errorInvalidCode"], + accountLinkedError: window.messages["errorAccountLinked"], + successError: window.messages["verified"], + successFunc: (modalClosed: boolean) => { + if (modalClosed) return; + telegramVerified = true; + telegramButton.classList.add("unfocused"); + document.getElementById("contact-via").classList.remove("unfocused"); + document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); + const radio = document.getElementById("contact-via-telegram") as HTMLInputElement; + radio.parentElement.classList.remove("unfocused"); + radio.checked = true; + validatorFunc(); } - const checkVerified = () => _get("/invite/" + window.code + "/telegram/verified/" + window.telegramPIN, null, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - if (req.status == 401) { - window.telegramModal.close(); - window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]); - return; - } else if (req.status == 400) { - window.telegramModal.close(); - window.notifications.customError("accountLinkedError", window.messages["errorAccountLinked"]); - } else if (req.status == 200) { - if (req.response["success"] as boolean) { - telegramVerified = true; - waiting.classList.add("~positive"); - waiting.classList.remove("~info"); - window.notifications.customPositive("telegramVerified", "", window.messages["verified"]); - setTimeout(window.telegramModal.close, 2000); - telegramButton.classList.add("unfocused"); - document.getElementById("contact-via").classList.remove("unfocused"); - document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); - const radio = document.getElementById("contact-via-telegram") as HTMLInputElement; - radio.parentElement.classList.remove("unfocused"); - radio.checked = true; - validatorFunc(); - } else if (!modalClosed) { - setTimeout(checkVerified, 1500); - } - } - } - }); - checkVerified(); }; + + const telegram = new Telegram(telegramConf); + + telegramButton.onclick = () => { telegram.onclick(); }; } var discordVerified = false; @@ -97,7 +82,7 @@ if (window.discordEnabled) { window.discordModal = new Modal(document.getElementById("modal-discord"), window.discordRequired); const discordButton = document.getElementById("link-discord") as HTMLSpanElement; - const discordConf: DiscordConfiguration = { + const discordConf: ServiceConfiguration = { modal: window.discordModal as Modal, pin: window.discordPIN, inviteURL: window.discordInviteLink ? ("/invite/" + window.code + "/discord/invite") : "", @@ -107,21 +92,21 @@ if (window.discordEnabled) { accountLinkedError: window.messages["errorAccountLinked"], successError: window.messages["verified"], successFunc: (modalClosed: boolean) => { - if (!modalClosed) { - discordButton.classList.add("unfocused"); - document.getElementById("contact-via").classList.remove("unfocused"); - document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); - const radio = document.getElementById("contact-via-discord") as HTMLInputElement; - radio.parentElement.classList.remove("unfocused") - radio.checked = true; - validatorFunc(); - } + if (modalClosed) return; + discordVerified = true; + discordButton.classList.add("unfocused"); + document.getElementById("contact-via").classList.remove("unfocused"); + document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); + const radio = document.getElementById("contact-via-discord") as HTMLInputElement; + radio.parentElement.classList.remove("unfocused") + radio.checked = true; + validatorFunc(); } }; const discord = new Discord(discordConf); - discordButton.onclick = discord.onclick; + discordButton.onclick = () => { discord.onclick(); }; } var matrixVerified = false; diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index 5902732..61bed1e 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -35,10 +35,10 @@ interface formWindow extends Window { declare var window: formWindow; -export interface DiscordConfiguration { +export interface ServiceConfiguration { modal: Modal; pin: string; - inviteURL: string; + inviteURL?: string; pinURL: string; verifiedURL: string; invalidCodeError: string; @@ -52,16 +52,17 @@ export interface DiscordInvite { icon: string; } -export class Discord { - private _conf: DiscordConfiguration; - private _pinAcquired = false; - private _modalClosed = false; - private _waiting = document.getElementById("discord-waiting") as HTMLSpanElement; - private _verified = false; +export class ServiceLinker { + protected _conf: ServiceConfiguration; + protected _pinAcquired = false; + protected _modalClosed = false; + protected _waiting: HTMLSpanElement; + protected _verified = false; + protected _name: string; get verified(): boolean { return this._verified; } - constructor(conf: DiscordConfiguration) { + constructor(conf: ServiceConfiguration) { this._conf = conf; this._conf.modal.onclose = () => { this._modalClosed = true; @@ -69,24 +70,7 @@ export class Discord { }; } - private _getInviteURL = () => _get(this._conf.inviteURL, null, (req: XMLHttpRequest) => { - if (req.readyState != 4) return; - const inv = req.response as DiscordInvite; - const link = document.getElementById("discord-invite") as HTMLAnchorElement; - link.href = inv.invite; - link.target = "_blank"; - let innerHTML = `${window.lang.strings("joinTheServer")}`; - if (inv.icon != "") { - innerHTML += `${window.discordServerName}`; - } else { - innerHTML += ` - ${window.discordServerName} - `; - } - link.innerHTML = innerHTML; - }); - - private _checkVerified = () => { + protected _checkVerified = () => { if (this._modalClosed) return; if (!this._pinAcquired) { setTimeout(this._checkVerified, 1500); @@ -105,7 +89,7 @@ export class Discord { this._verified = true; this._waiting.classList.add("~positive"); this._waiting.classList.remove("~info"); - window.notifications.customPositive("discordVerified", "", this._conf.successError); + window.notifications.customPositive(this._name + "Verified", "", this._conf.successError); if (this._conf.successFunc) { this._conf.successFunc(false); } @@ -123,18 +107,14 @@ export class Discord { }); }; - onclick = () => { - if (this._conf.inviteURL != "") { - this._getInviteURL(); - } - + onclick() { toggleLoader(this._waiting); this._pinAcquired = false; if (this._conf.pin) { this._pinAcquired = true; this._conf.modal.modal.querySelector(".pin").textContent = this._conf.pin; - } else { + } else if (this._conf.pinURL) { _get(this._conf.pinURL, null, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 200) { this._conf.pin = req.response["pin"]; @@ -150,3 +130,45 @@ export class Discord { this._checkVerified(); } } + +export class Discord extends ServiceLinker { + + constructor(conf: ServiceConfiguration) { + super(conf); + this._name = "discord"; + this._waiting = document.getElementById("discord-waiting") as HTMLSpanElement; + } + + private _getInviteURL = () => _get(this._conf.inviteURL, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + const inv = req.response as DiscordInvite; + const link = document.getElementById("discord-invite") as HTMLSpanElement; + (link.parentElement as HTMLAnchorElement).href = inv.invite; + (link.parentElement as HTMLAnchorElement).target = "_blank"; + let innerHTML = ``; + if (inv.icon != "") { + innerHTML += `${window.discordServerName}`; + } else { + innerHTML += ` + ${window.discordServerName} + `; + } + link.innerHTML = innerHTML; + }); + + onclick() { + if (this._conf.inviteURL != "") { + this._getInviteURL(); + } + + super.onclick(); + } +} + +export class Telegram extends ServiceLinker { + constructor(conf: ServiceConfiguration) { + super(conf); + this._name = "telegram"; + this._waiting = document.getElementById("telegram-waiting") as HTMLSpanElement; + } +}; diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 4bdf020..939f873 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { "outDir": "../js", - "target": "es6", + "target": "es2017", "lib": ["dom", "es2017"], "typeRoots": ["./typings", "../node_modules/@types"], - "moduleResolution": "node", + "moduleResolution": "nodenext", "esModuleInterop": true } } diff --git a/ts/user.ts b/ts/user.ts index 340f7e5..bf621c1 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -3,7 +3,7 @@ import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; -import { Discord, DiscordConfiguration } from "./modules/account-linking.js"; +import { Discord, Telegram, ServiceConfiguration } from "./modules/account-linking.js"; interface userWindow extends Window { jellyfinID: string; @@ -285,7 +285,7 @@ const addEditEmail = (add: boolean): void => { window.modals.email.show(); } -const discordConf: DiscordConfiguration = { +const discordConf: ServiceConfiguration = { modal: window.modals.discord as Modal, pin: "", inviteURL: window.discordInviteLink ? "/my/discord/invite" : "", @@ -301,6 +301,21 @@ const discordConf: DiscordConfiguration = { let discord = new Discord(discordConf); +const telegramConf: ServiceConfiguration = { + modal: window.modals.telegram as Modal, + pin: "", + pinURL: "/my/pin/telegram", + verifiedURL: "/my/telegram/verified/", + invalidCodeError: window.lang.notif("errorInvalidCode"), + accountLinkedError: window.lang.notif("errorAccountLinked"), + successError: window.lang.notif("verified"), + successFunc: (modalClosed: boolean) => { + if (modalClosed) window.location.reload(); + } +}; + +let telegram = new Telegram(telegramConf); + document.addEventListener("details-reload", () => { _get("/my/details", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { @@ -325,10 +340,13 @@ document.addEventListener("details-reload", () => { contactMethodList.clear(); + // Note the weird format of the functions for discord/telegram: + // "this" was being redefined within the onclick() method, so + // they had to be wrapped in an anonymous function. const contactMethods: { name: string, icon: string, f: (add: boolean) => void }[] = [ {name: "email", icon: ``, f: addEditEmail}, - {name: "discord", icon: ``, f: discord.onclick}, - {name: "telegram", icon: ``, f: null}, + {name: "discord", icon: ``, f: (add: boolean) => { discord.onclick(); }}, + {name: "telegram", icon: ``, f: (add: boolean) => { telegram.onclick() }}, {name: "matrix", icon: `[m]`, f: null} ]; diff --git a/views.go b/views.go index ebb855b..055ca88 100644 --- a/views.go +++ b/views.go @@ -185,7 +185,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "langName": lang, } if telegramEnabled { - data["telegramUser"] = app.telegram.username + data["telegramUsername"] = app.telegram.username data["telegramURL"] = app.telegram.link data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false) } From 68004e1d34945300ca906e2bd490785afa496733 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 12:19:24 +0100 Subject: [PATCH 23/54] storage: user set/get methods for contact method access Get/GetKey/SetKey/DeleteKey methods are used for access to email/discord/telegram/matrix, everywhere. Mutex added for each, avoids concurrent read/write issues. Will also make potential transition to database easier. --- api-invites.go | 2 +- api-messages.go | 52 +++---- api-ombi.go | 2 +- api-userpage.go | 29 ++-- api-users.go | 47 +++--- auth.go | 2 +- daemon.go | 24 ++- discord.go | 16 +- email.go | 14 +- matrix.go | 9 +- migrations.go | 8 +- package-lock.json | 366 +++++++++++++++++++++++----------------------- package.json | 2 +- storage.go | 119 ++++++++++++++- telegram.go | 6 +- 15 files changed, 397 insertions(+), 301 deletions(-) diff --git a/api-invites.go b/api-invites.go index 1102297..a41bd57 100644 --- a/api-invites.go +++ b/api-invites.go @@ -280,7 +280,7 @@ func (app *appContext) GetInvites(gc *gin.Context) { var address string if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { app.storage.loadEmails() - if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" { + if addr, ok := app.storage.GetEmailsKey(gc.GetString("jfId")); ok && addr.Addr != "" { address = addr.Addr } } else { diff --git a/api-messages.go b/api-messages.go index 8009adc..f971717 100644 --- a/api-messages.go +++ b/api-messages.go @@ -305,10 +305,10 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) { if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { tgUser.Lang = lang } - if app.storage.telegram == nil { - app.storage.telegram = map[string]TelegramUser{} + if app.storage.GetTelegram() == nil { + app.storage.telegram = telegramStore{} } - app.storage.telegram[req.ID] = tgUser + app.storage.SetTelegramKey(req.ID, tgUser) err := app.storage.storeTelegramUsers() if err != nil { app.err.Printf("Failed to store Telegram users: %v", err) @@ -340,15 +340,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { } func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) { - if tgUser, ok := app.storage.telegram[req.ID]; ok { + if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok { change := tgUser.Contact != req.Telegram tgUser.Contact = req.Telegram - app.storage.telegram[req.ID] = tgUser - if err := app.storage.storeTelegramUsers(); err != nil { - respondBool(500, false, gc) - app.err.Printf("Telegram: Failed to store users: %v", err) - return - } + app.storage.SetTelegramKey(req.ID, tgUser) if change { msg := "" if !req.Telegram { @@ -357,10 +352,10 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg) } } - if dcUser, ok := app.storage.discord[req.ID]; ok { + if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok { change := dcUser.Contact != req.Discord dcUser.Contact = req.Discord - app.storage.discord[req.ID] = dcUser + app.storage.SetDiscordKey(req.ID, dcUser) if err := app.storage.storeDiscordUsers(); err != nil { respondBool(500, false, gc) app.err.Printf("Discord: Failed to store users: %v", err) @@ -374,10 +369,10 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg) } } - if mxUser, ok := app.storage.matrix[req.ID]; ok { + if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok { change := mxUser.Contact != req.Matrix mxUser.Contact = req.Matrix - app.storage.matrix[req.ID] = mxUser + app.storage.SetMatrixKey(req.ID, mxUser) if err := app.storage.storeMatrixUsers(); err != nil { respondBool(500, false, gc) app.err.Printf("Matrix: Failed to store users: %v", err) @@ -391,10 +386,10 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg) } } - if email, ok := app.storage.emails[req.ID]; ok { + if email, ok := app.storage.GetEmailsKey(req.ID); ok { change := email.Contact != req.Email email.Contact = req.Email - app.storage.emails[req.ID] = email + app.storage.SetEmailsKey(req.ID, email) if err := app.storage.storeEmails(); err != nil { respondBool(500, false, gc) app.err.Printf("Failed to store emails: %v", err) @@ -458,7 +453,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { } } if app.config.Section("telegram").Key("require_unique").MustBool(false) { - for _, u := range app.storage.telegram { + for _, u := range app.storage.GetTelegram() { if app.telegram.verifiedTokens[tokenIndex].Username == u.Username { respondBool(400, false, gc) return @@ -490,7 +485,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") _, ok := app.discord.verifiedTokens[pin] if app.config.Section("discord").Key("require_unique").MustBool(false) { - for _, u := range app.storage.discord { + for _, u := range app.storage.GetDiscord() { if app.discord.verifiedTokens[pin].ID == u.ID { delete(app.discord.verifiedTokens, pin) respondBool(400, false, gc) @@ -551,7 +546,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) { return } if app.config.Section("matrix").Key("require_unique").MustBool(false) { - for _, u := range app.storage.matrix { + for _, u := range app.storage.GetMatrix() { if req.UserID == u.UserID { respondBool(400, false, gc) return @@ -648,8 +643,8 @@ func (app *appContext) MatrixLogin(gc *gin.Context) { func (app *appContext) MatrixConnect(gc *gin.Context) { var req MatrixConnectUserDTO gc.BindJSON(&req) - if app.storage.matrix == nil { - app.storage.matrix = map[string]MatrixUser{} + if app.storage.GetMatrix() == nil { + app.storage.matrix = matrixStore{} } roomID, encrypted, err := app.matrix.CreateRoom(req.UserID) if err != nil { @@ -657,13 +652,13 @@ func (app *appContext) MatrixConnect(gc *gin.Context) { respondBool(500, false, gc) return } - app.storage.matrix[req.JellyfinID] = MatrixUser{ + app.storage.SetMatrixKey(req.JellyfinID, MatrixUser{ UserID: req.UserID, RoomID: string(roomID), Lang: "en-us", Contact: true, Encrypted: encrypted, - } + }) app.matrix.isEncrypted[roomID] = encrypted if err := app.storage.storeMatrixUsers(); err != nil { app.err.Printf("Failed to store Matrix users: %v", err) @@ -719,7 +714,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) { respondBool(500, false, gc) return } - app.storage.discord[req.JellyfinID] = user + app.storage.SetDiscordKey(req.JellyfinID, user) if err := app.storage.storeDiscordUsers(); err != nil { app.err.Printf("Failed to store Discord users: %v", err) respondBool(500, false, gc) @@ -743,8 +738,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) { respond(400, "User not found", gc) return } */ - delete(app.storage.discord, req.ID) - app.storage.storeDiscordUsers() + app.storage.DeleteDiscordKey(req.ID) respondBool(200, true, gc) } @@ -762,8 +756,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) { respond(400, "User not found", gc) return } */ - delete(app.storage.telegram, req.ID) - app.storage.storeTelegramUsers() + app.storage.DeleteTelegramKey(req.ID) respondBool(200, true, gc) } @@ -781,7 +774,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) { respond(400, "User not found", gc) return } */ - delete(app.storage.matrix, req.ID) - app.storage.storeMatrixUsers() + app.storage.DeleteMatrixKey(req.ID) respondBool(200, true, gc) } diff --git a/api-ombi.go b/api-ombi.go index 9bbb027..40a8f72 100644 --- a/api-ombi.go +++ b/api-ombi.go @@ -17,7 +17,7 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er } username := jfUser.Name email := "" - if e, ok := app.storage.emails[jfID]; ok { + if e, ok := app.storage.GetEmailsKey(jfID); ok { email = e.Addr } for _, ombiUser := range ombiUsers { diff --git a/api-userpage.go b/api-userpage.go index 8c3f288..b63199d 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -41,7 +41,7 @@ func (app *appContext) MyDetails(gc *gin.Context) { if emailEnabled { resp.Email = &MyDetailsContactMethodsDTO{} - if email, ok := app.storage.emails[user.ID]; ok && email.Addr != "" { + if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" { resp.Email.Value = email.Addr resp.Email.Enabled = email.Contact } @@ -49,7 +49,7 @@ func (app *appContext) MyDetails(gc *gin.Context) { if discordEnabled { resp.Discord = &MyDetailsContactMethodsDTO{} - if discord, ok := app.storage.discord[user.ID]; ok { + if discord, ok := app.storage.GetDiscordKey(user.ID); ok { resp.Discord.Value = RenderDiscordUsername(discord) resp.Discord.Enabled = discord.Contact } @@ -57,7 +57,7 @@ func (app *appContext) MyDetails(gc *gin.Context) { if telegramEnabled { resp.Telegram = &MyDetailsContactMethodsDTO{} - if telegram, ok := app.storage.telegram[user.ID]; ok { + if telegram, ok := app.storage.GetTelegramKey(user.ID); ok { resp.Telegram.Value = telegram.Username resp.Telegram.Enabled = telegram.Contact } @@ -65,7 +65,7 @@ func (app *appContext) MyDetails(gc *gin.Context) { if matrixEnabled { resp.Matrix = &MyDetailsContactMethodsDTO{} - if matrix, ok := app.storage.matrix[user.ID]; ok { + if matrix, ok := app.storage.GetMatrixKey(user.ID); ok { resp.Matrix.Value = matrix.UserID resp.Matrix.Enabled = matrix.Contact } @@ -172,14 +172,14 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { gc.Redirect(http.StatusSeeOther, "/my/account") return } else if target == UserEmailChange { - emailStore, ok := app.storage.emails[id] + emailStore, ok := app.storage.GetEmailsKey(id) if !ok { emailStore = EmailAddress{ Contact: true, } } emailStore.Addr = claims["email"].(string) - app.storage.emails[id] = emailStore + app.storage.SetEmailsKey(id, emailStore) if app.config.Section("ombi").Key("enabled").MustBool(false) { ombiUser, code, err := app.getOmbiUser(id) if code == 200 && err == nil { @@ -320,7 +320,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { return } if app.config.Section("discord").Key("require_unique").MustBool(false) { - for _, u := range app.storage.discord { + for _, u := range app.storage.GetDiscord() { if app.discord.verifiedTokens[pin].ID == u.ID { delete(app.discord.verifiedTokens, pin) respondBool(400, false, gc) @@ -328,15 +328,12 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { } } } - dc := app.storage.discord - existingUser, ok := app.storage.discord[gc.GetString("jfId")] + existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId")) if ok { dcUser.Lang = existingUser.Lang dcUser.Contact = existingUser.Contact } - dc[gc.GetString("jfId")] = dcUser - app.storage.discord = dc - app.storage.storeDiscordUsers() + app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser) respondBool(200, true, gc) } @@ -361,7 +358,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { return } if app.config.Section("telegram").Key("require_unique").MustBool(false) { - for _, u := range app.storage.telegram { + for _, u := range app.storage.GetTelegram() { if app.telegram.verifiedTokens[tokenIndex].Username == u.Username { respondBool(400, false, gc) return @@ -374,13 +371,11 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { Contact: true, } - tg := app.storage.telegram - existingUser, ok := app.storage.telegram[gc.GetString("jfId")] + existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId")) if ok { tgUser.Lang = existingUser.Lang tgUser.Contact = existingUser.Contact } - tg[gc.GetString("jfId")] = tgUser - app.storage.storeTelegramUsers() + app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser) respondBool(200, true, gc) } diff --git a/api-users.go b/api-users.go index 4452cac..343139d 100644 --- a/api-users.go +++ b/api-users.go @@ -61,7 +61,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { } app.jf.CacheExpiry = time.Now() if emailEnabled { - app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} + app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true}) app.storage.storeEmails() } if app.config.Section("ombi").Key("enabled").MustBool(false) { @@ -131,7 +131,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } if app.config.Section("discord").Key("require_unique").MustBool(false) { - for _, u := range app.storage.discord { + for _, u := range app.storage.GetDiscord() { if discordUser.ID == u.ID { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Discord user already linked", req.Code) @@ -177,7 +177,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } if app.config.Section("matrix").Key("require_unique").MustBool(false) { - for _, u := range app.storage.matrix { + for _, u := range app.storage.GetMatrix() { if user.User.UserID == u.UserID { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Matrix user already linked", req.Code) @@ -220,7 +220,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } if app.config.Section("telegram").Key("require_unique").MustBool(false) { - for _, u := range app.storage.telegram { + for _, u := range app.storage.GetTelegram() { if app.telegram.verifiedTokens[telegramTokenIndex].Username == u.Username { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Telegram user already linked", req.Code) @@ -339,7 +339,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } // if app.config.Section("password_resets").Key("enabled").MustBool(false) { if req.Email != "" { - app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} + app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true}) app.storage.storeEmails() } expiry := time.Time{} @@ -355,9 +355,9 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc if discordEnabled && discordVerified { discordUser.Contact = req.DiscordContact if app.storage.discord == nil { - app.storage.discord = map[string]DiscordUser{} + app.storage.discord = discordStore{} } - app.storage.discord[user.ID] = discordUser + app.storage.SetDiscordKey(user.ID, discordUser) if err := app.storage.storeDiscordUsers(); err != nil { app.err.Printf("Failed to store Discord users: %v", err) } else { @@ -375,9 +375,9 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc tgUser.Lang = lang } if app.storage.telegram == nil { - app.storage.telegram = map[string]TelegramUser{} + app.storage.telegram = telegramStore{} } - app.storage.telegram[user.ID] = tgUser + app.storage.SetTelegramKey(user.ID, tgUser) if err := app.storage.storeTelegramUsers(); err != nil { app.err.Printf("Failed to store Telegram users: %v", err) } else { @@ -405,7 +405,8 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc dID = discordUser.ID } if telegramEnabled && telegramTokenIndex != -1 { - tUser = app.storage.telegram[user.ID].Username + u, _ := app.storage.GetTelegramKey(user.ID) + tUser = u.Username } resp, status, err := app.ombi.SetNotificationPrefs(ombiUser, dID, tUser) if !(status == 200 || status == 204) || err != nil { @@ -423,9 +424,9 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc matrixUser.Contact = req.MatrixContact delete(app.matrix.tokens, req.MatrixPIN) if app.storage.matrix == nil { - app.storage.matrix = map[string]MatrixUser{} + app.storage.matrix = matrixStore{} } - app.storage.matrix[user.ID] = matrixUser + app.storage.SetMatrixKey(user.ID, matrixUser) if err := app.storage.storeMatrixUsers(); err != nil { app.err.Printf("Failed to store Matrix users: %v", err) } @@ -488,7 +489,7 @@ func (app *appContext) NewUser(gc *gin.Context) { return } if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" { - for _, email := range app.storage.emails { + for _, email := range app.storage.GetEmails() { if req.Email == email.Addr { app.info.Printf("%s: New user failed: Email already in use", req.Code) respond(400, "errorEmailLinked", gc) @@ -897,7 +898,7 @@ func (app *appContext) GetUsers(gc *gin.Context) { if !jfUser.LastActivityDate.IsZero() { user.LastActive = jfUser.LastActivityDate.Unix() } - if email, ok := app.storage.emails[jfUser.ID]; ok { + if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok { user.Email = email.Addr user.NotifyThroughEmail = email.Contact user.Label = email.Label @@ -907,15 +908,15 @@ func (app *appContext) GetUsers(gc *gin.Context) { if ok { user.Expiry = expiry.Unix() } - if tgUser, ok := app.storage.telegram[jfUser.ID]; ok { + if tgUser, ok := app.storage.GetTelegramKey(jfUser.ID); ok { user.Telegram = tgUser.Username user.NotifyThroughTelegram = tgUser.Contact } - if mxUser, ok := app.storage.matrix[jfUser.ID]; ok { + if mxUser, ok := app.storage.GetMatrixKey(jfUser.ID); ok { user.Matrix = mxUser.UserID user.NotifyThroughMatrix = mxUser.Contact } - if dcUser, ok := app.storage.discord[jfUser.ID]; ok { + if dcUser, ok := app.storage.GetDiscordKey(jfUser.ID); ok { user.Discord = RenderDiscordUsername(dcUser) // user.Discord = dcUser.Username + "#" + dcUser.Discriminator user.DiscordID = dcUser.ID @@ -949,11 +950,11 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) { id := jfUser.ID if admin, ok := req[id]; ok { var emailStore = EmailAddress{} - if oldEmail, ok := app.storage.emails[id]; ok { + if oldEmail, ok := app.storage.GetEmailsKey(id); ok { emailStore = oldEmail } emailStore.Admin = admin - app.storage.emails[id] = emailStore + app.storage.SetEmailsKey(id, emailStore) } } if err := app.storage.storeEmails(); err != nil { @@ -986,11 +987,11 @@ func (app *appContext) ModifyLabels(gc *gin.Context) { id := jfUser.ID if label, ok := req[id]; ok { var emailStore = EmailAddress{} - if oldEmail, ok := app.storage.emails[id]; ok { + if oldEmail, ok := app.storage.GetEmailsKey(id); ok { emailStore = oldEmail } emailStore.Label = label - app.storage.emails[id] = emailStore + app.storage.SetEmailsKey(id, emailStore) } } if err := app.storage.storeEmails(); err != nil { @@ -1024,7 +1025,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) { id := jfUser.ID if address, ok := req[id]; ok { var emailStore = EmailAddress{} - oldEmail, ok := app.storage.emails[id] + oldEmail, ok := app.storage.GetEmailsKey(id) if ok { emailStore = oldEmail } @@ -1035,7 +1036,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) { } emailStore.Addr = address - app.storage.emails[id] = emailStore + app.storage.SetEmailsKey(id, emailStore) if ombiEnabled { ombiUser, code, err := app.getOmbiUser(id) if code == 200 && err == nil { diff --git a/auth.go b/auth.go index c1754e4..6ce4926 100644 --- a/auth.go +++ b/auth.go @@ -209,7 +209,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) { if !app.config.Section("ui").Key("allow_all").MustBool(false) { accountsAdmin := false adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) - if emailStore, ok := app.storage.emails[jfID]; ok { + if emailStore, ok := app.storage.GetEmailsKey(jfID); ok { accountsAdmin = emailStore.Admin } accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator) diff --git a/daemon.go b/daemon.go index d7aeb57..8b3b465 100644 --- a/daemon.go +++ b/daemon.go @@ -13,14 +13,16 @@ func (app *appContext) clearEmails() { return } // Rebuild email storage to from existing users to reduce time complexity - emails := map[string]EmailAddress{} + emails := emailStore{} + app.storage.emailsLock.Lock() for _, user := range users { - if email, ok := app.storage.emails[user.ID]; ok { + if email, ok := app.storage.GetEmailsKey(user.ID); ok { emails[user.ID] = email } } app.storage.emails = emails app.storage.storeEmails() + app.storage.emailsLock.Unlock() } // clearDiscord does the same as clearEmails, but for Discord Users. @@ -32,14 +34,16 @@ func (app *appContext) clearDiscord() { return } // Rebuild discord storage to from existing users to reduce time complexity - dcUsers := map[string]DiscordUser{} + dcUsers := discordStore{} + app.storage.discordLock.Lock() for _, user := range users { - if dcUser, ok := app.storage.discord[user.ID]; ok { + if dcUser, ok := app.storage.GetDiscordKey(user.ID); ok { dcUsers[user.ID] = dcUser } } app.storage.discord = dcUsers app.storage.storeDiscordUsers() + app.storage.discordLock.Unlock() } // clearMatrix does the same as clearEmails, but for Matrix Users. @@ -51,14 +55,16 @@ func (app *appContext) clearMatrix() { return } // Rebuild matrix storage to from existing users to reduce time complexity - mxUsers := map[string]MatrixUser{} + mxUsers := matrixStore{} + app.storage.matrixLock.Lock() for _, user := range users { - if mxUser, ok := app.storage.matrix[user.ID]; ok { + if mxUser, ok := app.storage.GetMatrixKey(user.ID); ok { mxUsers[user.ID] = mxUser } } app.storage.matrix = mxUsers app.storage.storeMatrixUsers() + app.storage.matrixLock.Unlock() } // clearTelegram does the same as clearEmails, but for Telegram Users. @@ -70,14 +76,16 @@ func (app *appContext) clearTelegram() { return } // Rebuild telegram storage to from existing users to reduce time complexity - tgUsers := map[string]TelegramUser{} + tgUsers := telegramStore{} + app.storage.telegramLock.Lock() for _, user := range users { - if tgUser, ok := app.storage.telegram[user.ID]; ok { + if tgUser, ok := app.storage.GetTelegramKey(user.ID); ok { tgUsers[user.ID] = tgUser } } app.storage.telegram = tgUsers app.storage.storeTelegramUsers() + app.storage.telegramLock.Unlock() } // https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS diff --git a/discord.go b/discord.go index f10e7c5..4fc98be 100644 --- a/discord.go +++ b/discord.go @@ -48,7 +48,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart dd.commandHandlers["lang"] = dd.cmdLang dd.commandHandlers["pin"] = dd.cmdPIN - for _, user := range app.storage.discord { + for _, user := range app.storage.GetDiscord() { dd.users[user.ID] = user } @@ -472,14 +472,11 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str code := i.ApplicationCommandData().Options[0].StringValue() if _, ok := d.app.storage.lang.Telegram[code]; ok { var user DiscordUser - for jfID, u := range d.app.storage.discord { + for jfID, u := range d.app.storage.GetDiscord() { if u.ID == i.Interaction.Member.User.ID { u.Lang = code lang = code - d.app.storage.discord[jfID] = u - if err := d.app.storage.storeDiscordUsers(); err != nil { - d.app.err.Printf("Failed to store Discord users: %v", err) - } + d.app.storage.SetDiscordKey(jfID, u) user = u break } @@ -582,13 +579,10 @@ func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []stri } if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok { var user DiscordUser - for jfID, u := range d.app.storage.discord { + for jfID, u := range d.app.storage.GetDiscord() { if u.ID == m.Author.ID { u.Lang = sects[1] - d.app.storage.discord[jfID] = u - if err := d.app.storage.storeDiscordUsers(); err != nil { - d.app.err.Printf("Failed to store Discord users: %v", err) - } + d.app.storage.SetDiscordKey(jfID, u) user = u break } diff --git a/email.go b/email.go index f31da18..808369c 100644 --- a/email.go +++ b/email.go @@ -838,25 +838,25 @@ func (emailer *Emailer) send(email *Message, address ...string) error { func (app *appContext) sendByID(email *Message, ID ...string) error { for _, id := range ID { var err error - if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled { + if tgChat, ok := app.storage.GetTelegramKey(id); ok && tgChat.Contact && telegramEnabled { err = app.telegram.Send(email, tgChat.ChatID) if err != nil { return err } } - if dcChat, ok := app.storage.discord[id]; ok && dcChat.Contact && discordEnabled { + if dcChat, ok := app.storage.GetDiscordKey(id); ok && dcChat.Contact && discordEnabled { err = app.discord.Send(email, dcChat.ChannelID) if err != nil { return err } } - if mxChat, ok := app.storage.matrix[id]; ok && mxChat.Contact && matrixEnabled { + if mxChat, ok := app.storage.GetMatrixKey(id); ok && mxChat.Contact && matrixEnabled { err = app.matrix.Send(email, mxChat) if err != nil { return err } } - if address, ok := app.storage.emails[id]; ok && address.Contact && emailEnabled { + if address, ok := app.storage.GetEmailsKey(id); ok && address.Contact && emailEnabled { err = app.email.send(email, address.Addr) if err != nil { return err @@ -870,13 +870,13 @@ func (app *appContext) sendByID(email *Message, ID ...string) error { } func (app *appContext) getAddressOrName(jfID string) string { - if dcChat, ok := app.storage.discord[jfID]; ok && dcChat.Contact && discordEnabled { + if dcChat, ok := app.storage.GetDiscordKey(jfID); ok && dcChat.Contact && discordEnabled { return RenderDiscordUsername(dcChat) } - if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled { + if tgChat, ok := app.storage.GetTelegramKey(jfID); ok && tgChat.Contact && telegramEnabled { return "@" + tgChat.Username } - if addr, ok := app.storage.emails[jfID]; ok { + if addr, ok := app.storage.GetEmailsKey(jfID); ok { return addr.Addr } return "" diff --git a/matrix.go b/matrix.go index ecd8268..932a6c0 100644 --- a/matrix.go +++ b/matrix.go @@ -83,7 +83,7 @@ func newMatrixDaemon(app *appContext) (d *MatrixDaemon, err error) { // return // } // d.bot.Store.SaveFilterID(d.userID, resp.FilterID) - for _, user := range app.storage.matrix { + for _, user := range app.storage.GetMatrix() { if user.Lang != "" { d.languages[id.RoomID(user.RoomID)] = user.Lang } @@ -176,12 +176,9 @@ func (d *MatrixDaemon) commandLang(evt *event.Event, code, lang string) { return } d.languages[evt.RoomID] = code - if u, ok := d.app.storage.matrix[string(evt.RoomID)]; ok { + if u, ok := d.app.storage.GetMatrixKey(string(evt.RoomID)); ok { u.Lang = code - d.app.storage.matrix[string(evt.RoomID)] = u - if err := d.app.storage.storeMatrixUsers(); err != nil { - d.app.err.Printf("Matrix: Failed to store Matrix users: %v", err) - } + d.app.storage.SetMatrixKey(string(evt.RoomID), u) } } diff --git a/migrations.go b/migrations.go index e09a0c7..95f69eb 100644 --- a/migrations.go +++ b/migrations.go @@ -139,7 +139,7 @@ func migrateNotificationMethods(app *appContext) error { if !strings.Contains(address, "@") { continue } - for id, email := range app.storage.emails { + for id, email := range app.storage.GetEmails() { if email.Addr == address { invite.Notify[id] = notifyPrefs delete(invite.Notify, address) @@ -168,10 +168,10 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error { return nil } idList := map[string][2]string{} - for jfID, user := range app.storage.discord { + for jfID, user := range app.storage.GetDiscord() { idList[jfID] = [2]string{user.ID, ""} } - for jfID, user := range app.storage.telegram { + for jfID, user := range app.storage.GetTelegram() { vals, ok := idList[jfID] if !ok { vals = [2]string{"", ""} @@ -212,7 +212,7 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error { // app.jf.GetUsers(false) // // noHyphens := true -// for id := range app.storage.emails { +// for id := range app.storage.GetEmails() { // if strings.Contains(id, "-") { // noHyphens = false // break diff --git a/package-lock.json b/package-lock.json index d53ad2c..17ac14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "any-date-parser": "^1.5.4", "browserslist": "^4.21.7", "cheerio": "^1.0.0-rc.12", - "esbuild": "^0.18.4", + "esbuild": "^0.18.5", "fs-cheerio": "^3.0.0", "inline-source": "^8.0.2", "jsdom": "^22.1.0", @@ -57,9 +57,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.4.tgz", - "integrity": "sha512-yKmQC9IiuvHdsNEbPHSprnMHg6OhL1cSeQZLzPpgzJBJ9ppEg9GAZN8MKj1TcmB4tZZUrq5xjK7KCmhwZP8iDA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.5.tgz", + "integrity": "sha512-+8GXQzuASxGg/rb47Z5zJe3vjOfL7RRce/DILuk6kbB/8HO0p3CPo72CbR349P2K8YP1h5NvNqU+2GDRbNJylw==", "cpu": [ "arm" ], @@ -72,9 +72,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.4.tgz", - "integrity": "sha512-yQVgO+V307hA2XhzELQ6F91CBGX7gSnlVGAj5YIqjQOxThDpM7fOcHT2YLJbE6gNdPtgRSafQrsK8rJ9xHCaZg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.5.tgz", + "integrity": "sha512-410IPUj7ZOxZ2dwK0B7o7Nibu7YEyaLBvYOfYBpuA1TpY0fOkDM5r4bwn+hT8Uma06DBI4RnYNN09fn55PYInQ==", "cpu": [ "arm64" ], @@ -87,9 +87,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.4.tgz", - "integrity": "sha512-yLKXMxQg6sk1ntftxQ5uwyVgG4/S2E7UoOCc5N4YZW7fdkfRiYEXqm7CMuIfY2Vs3FTrNyKmSfNevIuIvJnMww==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.5.tgz", + "integrity": "sha512-+fdfceCYwcz9OReheSWYOGaAAt03n0BnG5/UW9tyGyo15PjSOF14ylxfjvz+0atDx0S/RxyezMsH/mbnWhnC8w==", "cpu": [ "x64" ], @@ -102,9 +102,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.4.tgz", - "integrity": "sha512-MVPEoZjZpk2xQ1zckZrb8eQuQib+QCzdmMs3YZAYEQPg+Rztk5pUxGyk8htZOC8Z38NMM29W+MqY9Sqo/sDGKw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.5.tgz", + "integrity": "sha512-L7noeTaus5xEtgd5J7u/lGrZfSiYkvZb0gOD7rvKTuuWbdGM4bunz5DUFsWBbEIlloslpOO5PDy4Hnd6mZT20A==", "cpu": [ "arm64" ], @@ -117,9 +117,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.4.tgz", - "integrity": "sha512-uEsRtYRUDsz7i2tXg/t/SyF+5gU1cvi9B6B8i5ebJgtUUHJYWyIPIesmIOL4/+bywjxsDMA/XrNFMgMffLnh5A==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.5.tgz", + "integrity": "sha512-eA39B8SxbxRdSSILD4AsePzvJiVao6ZaYrcTOJqg89jnnMEGR/EAh+ehV7E4GOx4WXQoWeJRP1P9JQSzIrROeg==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.4.tgz", - "integrity": "sha512-I8EOigqWnOHRin6Zp5Y1cfH3oT54bd7Sdz/VnpUNksbOtfp8IWRTH4pgkgO5jWaRQPjCpJcOpdRjYAMjPt8wXg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.5.tgz", + "integrity": "sha512-Eg1UnkTZHfsphgcy1Wj/McNModSO/F+kqtWqvtvEZc9BAgvdwxAt11BESgBczU+Gti0G2dLvHs0Sfb3gavwhGg==", "cpu": [ "arm64" ], @@ -147,9 +147,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.4.tgz", - "integrity": "sha512-1bHfgMz/cNMjbpsYxjVgMJ1iwKq+NdDPlACBrWULD7ZdFmBQrhMicMaKb5CdmdVyvIwXmasOuF4r6Iq574kUTA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.5.tgz", + "integrity": "sha512-GNTMSJ55gl7Tf5VUqVRkMJhRGzH6vI9vFBfZCj4Zjm7RgfXCWxLnTyjMgZZKT8pOzW40KD2KlrGbqwnnJWyGWw==", "cpu": [ "x64" ], @@ -162,9 +162,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.4.tgz", - "integrity": "sha512-4XCGqM/Ay1LCXUBH59bL4JbSbbTK1K22dWHymWMGaEh2sQCDOUw+OQxozYV/YdBb91leK2NbuSrE2BRamwgaYw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.5.tgz", + "integrity": "sha512-6R+vEIyfEvp+gOWKSc+m6hdnhWKQYzicqONQYiDGT6qepc6OGsLEZcyFwoz6BvFx5j233CBWMcJ69eXFrwXw9A==", "cpu": [ "arm" ], @@ -177,9 +177,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.4.tgz", - "integrity": "sha512-J42vLHaYREyiBwH0eQE4/7H1DTfZx8FuxyWSictx4d7ezzuKE3XOkIvOg+SQzRz7T9HLVKzq2tvbAov4UfufBw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.5.tgz", + "integrity": "sha512-r08LmhqyPRj6FtuNPBTu8BliKh6h+oNEhMkWmmR/aWs4DWjDOivyDfLGznPdgtSThL23fk1QgSBUEbuCIzjA2A==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.4.tgz", - "integrity": "sha512-4ksIqFwhq7OExty7Sl1n0vqQSCqTG4sU6i99G2yuMr28CEOUZ/60N+IO9hwI8sIxBqmKmDgncE1n5CMu/3m0IA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.5.tgz", + "integrity": "sha512-ph6M9iEMc6BHgv2XuIE8qeQrQCH+2l116c8L9ysmmXYwpNXa3E7JNIu/O7hI0I9qDvh1P19AGbIh+/y0GAZijA==", "cpu": [ "ia32" ], @@ -207,9 +207,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.4.tgz", - "integrity": "sha512-bsWtoVHkGQgAsFXioDueXRiUIfSGrVkJjBBz4gcBJxXcD461cWFQFyu8Fxdj9TP+zEeqJ8C/O4LFFMBNi6Fscw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.5.tgz", + "integrity": "sha512-s6Nup5FMQ8R8OKJG2rSxtV40s8LRdfC73XGHGaFlGiC+2SeCyq4dl3MMfLdzLowYzyDjfc4GRrXWUNMX3kNxYA==", "cpu": [ "loong64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.4.tgz", - "integrity": "sha512-LRD9Fu8wJQgIOOV1o3nRyzrheFYjxA0C1IVWZ93eNRRWBKgarYFejd5WBtrp43cE4y4D4t3qWWyklm73Mrsd/g==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.5.tgz", + "integrity": "sha512-DxW4nNDIGbivZxnJD01C5PlwKPpin8YgSwWtToCy4w4lNigT7Iaf5A+wcPT2laibdgbcgPKpPOXUg6RFGTt8xA==", "cpu": [ "mips64el" ], @@ -237,9 +237,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.4.tgz", - "integrity": "sha512-jtQgoZjM92gauVRxNaaG/TpL3Pr4WcL3Pwqi9QgdrBGrEXzB+twohQiWNSTycs6lUygakos4mm2h0B9/SHveng==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.5.tgz", + "integrity": "sha512-BksOs2uYTafS+u75QiN4RoLbEMNjE192adJCBalncI3E2PWyR2i1kEs9rEghHK7pw0SD0uWgV9otRmV7G5b2lQ==", "cpu": [ "ppc64" ], @@ -252,9 +252,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.4.tgz", - "integrity": "sha512-7WaU/kRZG0VCV09Xdlkg6LNAsfU9SAxo6XEdaZ8ffO4lh+DZoAhGTx7+vTMOXKxa+r2w1LYDGxfJa2rcgagMRA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.5.tgz", + "integrity": "sha512-mGv8BOJXsV7bZyjyMdeDs55CDXZ5vrY3oKa58DNRz2vPn54dREyj4BhhyWuqSuzSURJhFg7pM/1fI2vnAHGkHw==", "cpu": [ "riscv64" ], @@ -267,9 +267,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.4.tgz", - "integrity": "sha512-D19ed0xreKQvC5t+ArE2njSnm18WPpE+1fhwaiJHf+Xwqsq+/SUaV8Mx0M27nszdU+Atq1HahrgCOZCNNEASUg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.5.tgz", + "integrity": "sha512-m4uIYyrl5znGnNHgiM/Zsw6I9Se513NqdTxeUxZ66/VDWbuUp8ACe1KOSpwF4NNxfYy6Q3W8beZsIdF4F85q8Q==", "cpu": [ "s390x" ], @@ -282,9 +282,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.4.tgz", - "integrity": "sha512-Rx3AY1sxyiO/gvCGP00nL69L60dfmWyjKWY06ugpB8Ydpdsfi3BHW58HWC24K3CAjAPSwxcajozC2PzA9JBS1g==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.5.tgz", + "integrity": "sha512-R1C7X30YjXmOZYOzx4dJ/QvRNfrkK/sDCFfcGNhlHFX6B/iodJdk81h7EhnKVUQy+3BaARxF7udd91iSSzMlbQ==", "cpu": [ "x64" ], @@ -297,9 +297,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.4.tgz", - "integrity": "sha512-AaShPmN9c6w1mKRpliKFlaWcSkpBT4KOlk93UfFgeI3F3cbjzdDKGsbKnOZozmYbE1izZKLmNJiW0sFM+A5JPA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.5.tgz", + "integrity": "sha512-MABnKzjMcXjO0NEYyexOhqjcrgM6dE8BXnm+lctm2x2aPpYg5iL0Ew3aABSTZyp9dS3Z4VzFu5PPoOYEw8akTQ==", "cpu": [ "x64" ], @@ -312,9 +312,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.4.tgz", - "integrity": "sha512-tRGvGwou3BrvHVvF8HxTqEiC5VtPzySudS9fh2jBIKpLX7HCW8jIkW+LunkFDNwhslx4xMAgh0jAHsx/iCymaQ==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.5.tgz", + "integrity": "sha512-aU7R0tLIUMaQuAgBjKrq02Z98rcY9Pxk76hynSqcGeld2C/ro1uBbS2i9rh7vdwBAY0rG08Og4wnDnlx5rU+fQ==", "cpu": [ "x64" ], @@ -327,9 +327,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.4.tgz", - "integrity": "sha512-acORFDI95GKhmAnlH8EarBeuqoy/j3yxIU+FDB91H3+ZON+8HhTadtT450YkaMzX6lEWbhi+mjVUCj00M5yyOQ==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.5.tgz", + "integrity": "sha512-ngm3fVv2VxufI8zH/Phk0mYkgvFjFGnS+l7uxxd20mmeLTNI/8OXDJpNqTUbvzJh3tqhI/Gof0N2+5xJbqEaxA==", "cpu": [ "x64" ], @@ -342,9 +342,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.4.tgz", - "integrity": "sha512-1NxP+iOk8KSvS1L9SSxEvBAJk39U0GiGZkiiJGbuDF9G4fG7DSDw6XLxZMecAgmvQrwwx7yVKdNN3GgNh0UfKg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.5.tgz", + "integrity": "sha512-XqpS89+MGLzR8YtQQkBYsLCfAv1ySflMb+FEH99rOp6kOPv/ORO+ujEB5ICDBZZbvYqB75uFrNELo1BVEQbS3g==", "cpu": [ "arm64" ], @@ -357,9 +357,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.4.tgz", - "integrity": "sha512-OKr8jze93vbgqZ/r23woWciTixUwLa976C9W7yNBujtnVHyvsL/ocYG61tsktUfJOpyIz5TsohkBZ6Lo2+PCcQ==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.5.tgz", + "integrity": "sha512-V3xj/nb9uie0I4mn1f8nPZSgHldtNJrqTKYjTyMPMBnHbMYF5Loz8ZHsp7+La8kI6NxIF1ClQ9XBV+G3RtSkww==", "cpu": [ "ia32" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.4.tgz", - "integrity": "sha512-qJr3wVvcLjPFcV4AMDS3iquhBfTef2zo/jlm8RMxmiRp3Vy2HY8WMxrykJlcbCnqLXZPA0YZxZGND6eug85ogg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.5.tgz", + "integrity": "sha512-gMxWvQeTQWDpa8ExPP41al+Ho7HyK24h7y41JdGKqE24KzXXQPxESUtrCoIES+HwF+OGq2smtibU9UvZ8WH3JQ==", "cpu": [ "x64" ], @@ -1647,9 +1647,9 @@ } }, "node_modules/esbuild": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.4.tgz", - "integrity": "sha512-9rxWV/Cb2DMUXfe9aUsYtqg0KTlw146ElFH22kYeK9KVV1qT082X4lpmiKsa12ePiCcIcB686TQJxaGAa9TFvA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.5.tgz", + "integrity": "sha512-ztF1Z53Mc8ijEo1ZWFduHZXIqRWufo76JHm1ikvhGjIzO1mj84LdKXSGmRzahfgvWSwky48MkT+o5yUIkQtDPA==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -1658,28 +1658,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.18.4", - "@esbuild/android-arm64": "0.18.4", - "@esbuild/android-x64": "0.18.4", - "@esbuild/darwin-arm64": "0.18.4", - "@esbuild/darwin-x64": "0.18.4", - "@esbuild/freebsd-arm64": "0.18.4", - "@esbuild/freebsd-x64": "0.18.4", - "@esbuild/linux-arm": "0.18.4", - "@esbuild/linux-arm64": "0.18.4", - "@esbuild/linux-ia32": "0.18.4", - "@esbuild/linux-loong64": "0.18.4", - "@esbuild/linux-mips64el": "0.18.4", - "@esbuild/linux-ppc64": "0.18.4", - "@esbuild/linux-riscv64": "0.18.4", - "@esbuild/linux-s390x": "0.18.4", - "@esbuild/linux-x64": "0.18.4", - "@esbuild/netbsd-x64": "0.18.4", - "@esbuild/openbsd-x64": "0.18.4", - "@esbuild/sunos-x64": "0.18.4", - "@esbuild/win32-arm64": "0.18.4", - "@esbuild/win32-ia32": "0.18.4", - "@esbuild/win32-x64": "0.18.4" + "@esbuild/android-arm": "0.18.5", + "@esbuild/android-arm64": "0.18.5", + "@esbuild/android-x64": "0.18.5", + "@esbuild/darwin-arm64": "0.18.5", + "@esbuild/darwin-x64": "0.18.5", + "@esbuild/freebsd-arm64": "0.18.5", + "@esbuild/freebsd-x64": "0.18.5", + "@esbuild/linux-arm": "0.18.5", + "@esbuild/linux-arm64": "0.18.5", + "@esbuild/linux-ia32": "0.18.5", + "@esbuild/linux-loong64": "0.18.5", + "@esbuild/linux-mips64el": "0.18.5", + "@esbuild/linux-ppc64": "0.18.5", + "@esbuild/linux-riscv64": "0.18.5", + "@esbuild/linux-s390x": "0.18.5", + "@esbuild/linux-x64": "0.18.5", + "@esbuild/netbsd-x64": "0.18.5", + "@esbuild/openbsd-x64": "0.18.5", + "@esbuild/sunos-x64": "0.18.5", + "@esbuild/win32-arm64": "0.18.5", + "@esbuild/win32-ia32": "0.18.5", + "@esbuild/win32-x64": "0.18.5" } }, "node_modules/escalade": { @@ -6797,135 +6797,135 @@ } }, "@esbuild/android-arm": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.4.tgz", - "integrity": "sha512-yKmQC9IiuvHdsNEbPHSprnMHg6OhL1cSeQZLzPpgzJBJ9ppEg9GAZN8MKj1TcmB4tZZUrq5xjK7KCmhwZP8iDA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.5.tgz", + "integrity": "sha512-+8GXQzuASxGg/rb47Z5zJe3vjOfL7RRce/DILuk6kbB/8HO0p3CPo72CbR349P2K8YP1h5NvNqU+2GDRbNJylw==", "optional": true }, "@esbuild/android-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.4.tgz", - "integrity": "sha512-yQVgO+V307hA2XhzELQ6F91CBGX7gSnlVGAj5YIqjQOxThDpM7fOcHT2YLJbE6gNdPtgRSafQrsK8rJ9xHCaZg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.5.tgz", + "integrity": "sha512-410IPUj7ZOxZ2dwK0B7o7Nibu7YEyaLBvYOfYBpuA1TpY0fOkDM5r4bwn+hT8Uma06DBI4RnYNN09fn55PYInQ==", "optional": true }, "@esbuild/android-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.4.tgz", - "integrity": "sha512-yLKXMxQg6sk1ntftxQ5uwyVgG4/S2E7UoOCc5N4YZW7fdkfRiYEXqm7CMuIfY2Vs3FTrNyKmSfNevIuIvJnMww==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.5.tgz", + "integrity": "sha512-+fdfceCYwcz9OReheSWYOGaAAt03n0BnG5/UW9tyGyo15PjSOF14ylxfjvz+0atDx0S/RxyezMsH/mbnWhnC8w==", "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.4.tgz", - "integrity": "sha512-MVPEoZjZpk2xQ1zckZrb8eQuQib+QCzdmMs3YZAYEQPg+Rztk5pUxGyk8htZOC8Z38NMM29W+MqY9Sqo/sDGKw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.5.tgz", + "integrity": "sha512-L7noeTaus5xEtgd5J7u/lGrZfSiYkvZb0gOD7rvKTuuWbdGM4bunz5DUFsWBbEIlloslpOO5PDy4Hnd6mZT20A==", "optional": true }, "@esbuild/darwin-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.4.tgz", - "integrity": "sha512-uEsRtYRUDsz7i2tXg/t/SyF+5gU1cvi9B6B8i5ebJgtUUHJYWyIPIesmIOL4/+bywjxsDMA/XrNFMgMffLnh5A==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.5.tgz", + "integrity": "sha512-eA39B8SxbxRdSSILD4AsePzvJiVao6ZaYrcTOJqg89jnnMEGR/EAh+ehV7E4GOx4WXQoWeJRP1P9JQSzIrROeg==", "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.4.tgz", - "integrity": "sha512-I8EOigqWnOHRin6Zp5Y1cfH3oT54bd7Sdz/VnpUNksbOtfp8IWRTH4pgkgO5jWaRQPjCpJcOpdRjYAMjPt8wXg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.5.tgz", + "integrity": "sha512-Eg1UnkTZHfsphgcy1Wj/McNModSO/F+kqtWqvtvEZc9BAgvdwxAt11BESgBczU+Gti0G2dLvHs0Sfb3gavwhGg==", "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.4.tgz", - "integrity": "sha512-1bHfgMz/cNMjbpsYxjVgMJ1iwKq+NdDPlACBrWULD7ZdFmBQrhMicMaKb5CdmdVyvIwXmasOuF4r6Iq574kUTA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.5.tgz", + "integrity": "sha512-GNTMSJ55gl7Tf5VUqVRkMJhRGzH6vI9vFBfZCj4Zjm7RgfXCWxLnTyjMgZZKT8pOzW40KD2KlrGbqwnnJWyGWw==", "optional": true }, "@esbuild/linux-arm": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.4.tgz", - "integrity": "sha512-4XCGqM/Ay1LCXUBH59bL4JbSbbTK1K22dWHymWMGaEh2sQCDOUw+OQxozYV/YdBb91leK2NbuSrE2BRamwgaYw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.5.tgz", + "integrity": "sha512-6R+vEIyfEvp+gOWKSc+m6hdnhWKQYzicqONQYiDGT6qepc6OGsLEZcyFwoz6BvFx5j233CBWMcJ69eXFrwXw9A==", "optional": true }, "@esbuild/linux-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.4.tgz", - "integrity": "sha512-J42vLHaYREyiBwH0eQE4/7H1DTfZx8FuxyWSictx4d7ezzuKE3XOkIvOg+SQzRz7T9HLVKzq2tvbAov4UfufBw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.5.tgz", + "integrity": "sha512-r08LmhqyPRj6FtuNPBTu8BliKh6h+oNEhMkWmmR/aWs4DWjDOivyDfLGznPdgtSThL23fk1QgSBUEbuCIzjA2A==", "optional": true }, "@esbuild/linux-ia32": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.4.tgz", - "integrity": "sha512-4ksIqFwhq7OExty7Sl1n0vqQSCqTG4sU6i99G2yuMr28CEOUZ/60N+IO9hwI8sIxBqmKmDgncE1n5CMu/3m0IA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.5.tgz", + "integrity": "sha512-ph6M9iEMc6BHgv2XuIE8qeQrQCH+2l116c8L9ysmmXYwpNXa3E7JNIu/O7hI0I9qDvh1P19AGbIh+/y0GAZijA==", "optional": true }, "@esbuild/linux-loong64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.4.tgz", - "integrity": "sha512-bsWtoVHkGQgAsFXioDueXRiUIfSGrVkJjBBz4gcBJxXcD461cWFQFyu8Fxdj9TP+zEeqJ8C/O4LFFMBNi6Fscw==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.5.tgz", + "integrity": "sha512-s6Nup5FMQ8R8OKJG2rSxtV40s8LRdfC73XGHGaFlGiC+2SeCyq4dl3MMfLdzLowYzyDjfc4GRrXWUNMX3kNxYA==", "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.4.tgz", - "integrity": "sha512-LRD9Fu8wJQgIOOV1o3nRyzrheFYjxA0C1IVWZ93eNRRWBKgarYFejd5WBtrp43cE4y4D4t3qWWyklm73Mrsd/g==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.5.tgz", + "integrity": "sha512-DxW4nNDIGbivZxnJD01C5PlwKPpin8YgSwWtToCy4w4lNigT7Iaf5A+wcPT2laibdgbcgPKpPOXUg6RFGTt8xA==", "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.4.tgz", - "integrity": "sha512-jtQgoZjM92gauVRxNaaG/TpL3Pr4WcL3Pwqi9QgdrBGrEXzB+twohQiWNSTycs6lUygakos4mm2h0B9/SHveng==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.5.tgz", + "integrity": "sha512-BksOs2uYTafS+u75QiN4RoLbEMNjE192adJCBalncI3E2PWyR2i1kEs9rEghHK7pw0SD0uWgV9otRmV7G5b2lQ==", "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.4.tgz", - "integrity": "sha512-7WaU/kRZG0VCV09Xdlkg6LNAsfU9SAxo6XEdaZ8ffO4lh+DZoAhGTx7+vTMOXKxa+r2w1LYDGxfJa2rcgagMRA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.5.tgz", + "integrity": "sha512-mGv8BOJXsV7bZyjyMdeDs55CDXZ5vrY3oKa58DNRz2vPn54dREyj4BhhyWuqSuzSURJhFg7pM/1fI2vnAHGkHw==", "optional": true }, "@esbuild/linux-s390x": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.4.tgz", - "integrity": "sha512-D19ed0xreKQvC5t+ArE2njSnm18WPpE+1fhwaiJHf+Xwqsq+/SUaV8Mx0M27nszdU+Atq1HahrgCOZCNNEASUg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.5.tgz", + "integrity": "sha512-m4uIYyrl5znGnNHgiM/Zsw6I9Se513NqdTxeUxZ66/VDWbuUp8ACe1KOSpwF4NNxfYy6Q3W8beZsIdF4F85q8Q==", "optional": true }, "@esbuild/linux-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.4.tgz", - "integrity": "sha512-Rx3AY1sxyiO/gvCGP00nL69L60dfmWyjKWY06ugpB8Ydpdsfi3BHW58HWC24K3CAjAPSwxcajozC2PzA9JBS1g==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.5.tgz", + "integrity": "sha512-R1C7X30YjXmOZYOzx4dJ/QvRNfrkK/sDCFfcGNhlHFX6B/iodJdk81h7EhnKVUQy+3BaARxF7udd91iSSzMlbQ==", "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.4.tgz", - "integrity": "sha512-AaShPmN9c6w1mKRpliKFlaWcSkpBT4KOlk93UfFgeI3F3cbjzdDKGsbKnOZozmYbE1izZKLmNJiW0sFM+A5JPA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.5.tgz", + "integrity": "sha512-MABnKzjMcXjO0NEYyexOhqjcrgM6dE8BXnm+lctm2x2aPpYg5iL0Ew3aABSTZyp9dS3Z4VzFu5PPoOYEw8akTQ==", "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.4.tgz", - "integrity": "sha512-tRGvGwou3BrvHVvF8HxTqEiC5VtPzySudS9fh2jBIKpLX7HCW8jIkW+LunkFDNwhslx4xMAgh0jAHsx/iCymaQ==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.5.tgz", + "integrity": "sha512-aU7R0tLIUMaQuAgBjKrq02Z98rcY9Pxk76hynSqcGeld2C/ro1uBbS2i9rh7vdwBAY0rG08Og4wnDnlx5rU+fQ==", "optional": true }, "@esbuild/sunos-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.4.tgz", - "integrity": "sha512-acORFDI95GKhmAnlH8EarBeuqoy/j3yxIU+FDB91H3+ZON+8HhTadtT450YkaMzX6lEWbhi+mjVUCj00M5yyOQ==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.5.tgz", + "integrity": "sha512-ngm3fVv2VxufI8zH/Phk0mYkgvFjFGnS+l7uxxd20mmeLTNI/8OXDJpNqTUbvzJh3tqhI/Gof0N2+5xJbqEaxA==", "optional": true }, "@esbuild/win32-arm64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.4.tgz", - "integrity": "sha512-1NxP+iOk8KSvS1L9SSxEvBAJk39U0GiGZkiiJGbuDF9G4fG7DSDw6XLxZMecAgmvQrwwx7yVKdNN3GgNh0UfKg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.5.tgz", + "integrity": "sha512-XqpS89+MGLzR8YtQQkBYsLCfAv1ySflMb+FEH99rOp6kOPv/ORO+ujEB5ICDBZZbvYqB75uFrNELo1BVEQbS3g==", "optional": true }, "@esbuild/win32-ia32": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.4.tgz", - "integrity": "sha512-OKr8jze93vbgqZ/r23woWciTixUwLa976C9W7yNBujtnVHyvsL/ocYG61tsktUfJOpyIz5TsohkBZ6Lo2+PCcQ==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.5.tgz", + "integrity": "sha512-V3xj/nb9uie0I4mn1f8nPZSgHldtNJrqTKYjTyMPMBnHbMYF5Loz8ZHsp7+La8kI6NxIF1ClQ9XBV+G3RtSkww==", "optional": true }, "@esbuild/win32-x64": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.4.tgz", - "integrity": "sha512-qJr3wVvcLjPFcV4AMDS3iquhBfTef2zo/jlm8RMxmiRp3Vy2HY8WMxrykJlcbCnqLXZPA0YZxZGND6eug85ogg==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.5.tgz", + "integrity": "sha512-gMxWvQeTQWDpa8ExPP41al+Ho7HyK24h7y41JdGKqE24KzXXQPxESUtrCoIES+HwF+OGq2smtibU9UvZ8WH3JQ==", "optional": true }, "@jridgewell/gen-mapping": { @@ -7896,32 +7896,32 @@ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" }, "esbuild": { - "version": "0.18.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.4.tgz", - "integrity": "sha512-9rxWV/Cb2DMUXfe9aUsYtqg0KTlw146ElFH22kYeK9KVV1qT082X4lpmiKsa12ePiCcIcB686TQJxaGAa9TFvA==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.5.tgz", + "integrity": "sha512-ztF1Z53Mc8ijEo1ZWFduHZXIqRWufo76JHm1ikvhGjIzO1mj84LdKXSGmRzahfgvWSwky48MkT+o5yUIkQtDPA==", "requires": { - "@esbuild/android-arm": "0.18.4", - "@esbuild/android-arm64": "0.18.4", - "@esbuild/android-x64": "0.18.4", - "@esbuild/darwin-arm64": "0.18.4", - "@esbuild/darwin-x64": "0.18.4", - "@esbuild/freebsd-arm64": "0.18.4", - "@esbuild/freebsd-x64": "0.18.4", - "@esbuild/linux-arm": "0.18.4", - "@esbuild/linux-arm64": "0.18.4", - "@esbuild/linux-ia32": "0.18.4", - "@esbuild/linux-loong64": "0.18.4", - "@esbuild/linux-mips64el": "0.18.4", - "@esbuild/linux-ppc64": "0.18.4", - "@esbuild/linux-riscv64": "0.18.4", - "@esbuild/linux-s390x": "0.18.4", - "@esbuild/linux-x64": "0.18.4", - "@esbuild/netbsd-x64": "0.18.4", - "@esbuild/openbsd-x64": "0.18.4", - "@esbuild/sunos-x64": "0.18.4", - "@esbuild/win32-arm64": "0.18.4", - "@esbuild/win32-ia32": "0.18.4", - "@esbuild/win32-x64": "0.18.4" + "@esbuild/android-arm": "0.18.5", + "@esbuild/android-arm64": "0.18.5", + "@esbuild/android-x64": "0.18.5", + "@esbuild/darwin-arm64": "0.18.5", + "@esbuild/darwin-x64": "0.18.5", + "@esbuild/freebsd-arm64": "0.18.5", + "@esbuild/freebsd-x64": "0.18.5", + "@esbuild/linux-arm": "0.18.5", + "@esbuild/linux-arm64": "0.18.5", + "@esbuild/linux-ia32": "0.18.5", + "@esbuild/linux-loong64": "0.18.5", + "@esbuild/linux-mips64el": "0.18.5", + "@esbuild/linux-ppc64": "0.18.5", + "@esbuild/linux-riscv64": "0.18.5", + "@esbuild/linux-s390x": "0.18.5", + "@esbuild/linux-x64": "0.18.5", + "@esbuild/netbsd-x64": "0.18.5", + "@esbuild/openbsd-x64": "0.18.5", + "@esbuild/sunos-x64": "0.18.5", + "@esbuild/win32-arm64": "0.18.5", + "@esbuild/win32-ia32": "0.18.5", + "@esbuild/win32-x64": "0.18.5" } }, "escalade": { diff --git a/package.json b/package.json index 3aa5eab..b2b0747 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "any-date-parser": "^1.5.4", "browserslist": "^4.21.7", "cheerio": "^1.0.0-rc.12", - "esbuild": "^0.18.4", + "esbuild": "^0.18.5", "fs-cheerio": "^3.0.0", "inline-source": "^8.0.2", "jsdom": "^22.1.0", diff --git a/storage.go b/storage.go index 38d76c7..605516e 100644 --- a/storage.go +++ b/storage.go @@ -15,6 +15,11 @@ import ( "github.com/steambap/captcha" ) +type discordStore map[string]DiscordUser +type telegramStore map[string]TelegramUser +type matrixStore map[string]MatrixUser +type emailStore map[string]EmailAddress + type Storage struct { timePattern string invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path, matrix_sql_path string @@ -23,16 +28,120 @@ type Storage struct { profiles map[string]Profile defaultProfile string displayprefs, ombi_template map[string]interface{} - emails map[string]EmailAddress - telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. - discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. - matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users. + emails emailStore + telegram telegramStore // Map of Jellyfin User IDs to telegram users. + discord discordStore // Map of Jellyfin user IDs to discord users. + matrix matrixStore // Map of Jellyfin user IDs to Matrix users. customEmails customEmails policy mediabrowser.Policy configuration mediabrowser.Configuration lang Lang announcements map[string]announcementTemplate - invitesLock, usersLock sync.Mutex + invitesLock, usersLock, discordLock, telegramLock, matrixLock, emailsLock sync.Mutex +} + +// GetEmails returns a copy of the store. +func (st *Storage) GetEmails() emailStore { + return st.emails +} + +// GetEmailsKey returns the value stored in the store's key. +func (st *Storage) GetEmailsKey(k string) (EmailAddress, bool) { + v, ok := st.emails[k] + return v, ok +} + +// SetEmailsKey stores value v in key k. +func (st *Storage) SetEmailsKey(k string, v EmailAddress) { + st.emailsLock.Lock() + st.emails[k] = v + st.storeEmails() + st.emailsLock.Unlock() +} + +// DeleteEmailKey deletes value at key k. +func (st *Storage) DeleteEmailsKey(k string) { + st.emailsLock.Lock() + delete(st.emails, k) + st.emailsLock.Unlock() +} + +// GetDiscord returns a copy of the store. +func (st *Storage) GetDiscord() discordStore { + return st.discord +} + +// GetDiscordKey returns the value stored in the store's key. +func (st *Storage) GetDiscordKey(k string) (DiscordUser, bool) { + v, ok := st.discord[k] + return v, ok +} + +// SetDiscordKey stores value v in key k. +func (st *Storage) SetDiscordKey(k string, v DiscordUser) { + st.discordLock.Lock() + st.discord[k] = v + st.storeDiscordUsers() + st.discordLock.Unlock() +} + +// DeleteDiscordKey deletes value at key k. +func (st *Storage) DeleteDiscordKey(k string) { + st.discordLock.Lock() + delete(st.discord, k) + st.discordLock.Unlock() +} + +// GetTelegram returns a copy of the store. +func (st *Storage) GetTelegram() telegramStore { + return st.telegram +} + +// GetTelegramKey returns the value stored in the store's key. +func (st *Storage) GetTelegramKey(k string) (TelegramUser, bool) { + v, ok := st.telegram[k] + return v, ok +} + +// SetTelegramKey stores value v in key k. +func (st *Storage) SetTelegramKey(k string, v TelegramUser) { + st.telegramLock.Lock() + st.telegram[k] = v + st.storeTelegramUsers() + st.telegramLock.Unlock() +} + +// DeleteTelegramKey deletes value at key k. +func (st *Storage) DeleteTelegramKey(k string) { + st.telegramLock.Lock() + delete(st.telegram, k) + st.telegramLock.Unlock() +} + +// GetMatrix returns a copy of the store. +func (st *Storage) GetMatrix() matrixStore { + return st.matrix +} + +// GetMatrixKey returns the value stored in the store's key. +func (st *Storage) GetMatrixKey(k string) (MatrixUser, bool) { + v, ok := st.matrix[k] + return v, ok +} + +// SetMatrixKey stores value v in key k. +func (st *Storage) SetMatrixKey(k string, v MatrixUser) { + st.matrixLock.Lock() + st.matrix[k] = v + st.storeMatrixUsers() + st.matrixLock.Unlock() +} + +// DeleteMatrixKey deletes value at key k. +func (st *Storage) DeleteMatrixKey(k string) { + st.matrixLock.Lock() + delete(st.matrix, k) + st.matrixLock.Unlock() } type TelegramUser struct { diff --git a/telegram.go b/telegram.go index 654d05b..288d95d 100644 --- a/telegram.go +++ b/telegram.go @@ -46,7 +46,7 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { link: "https://t.me/" + bot.Self.UserName, app: app, } - for _, user := range app.storage.telegram { + for _, user := range app.storage.GetTelegram() { if user.Lang != "" { td.languages[user.ChatID] = user.Lang } @@ -198,10 +198,10 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string } if _, ok := t.app.storage.lang.Telegram[sects[1]]; ok { t.languages[upd.Message.Chat.ID] = sects[1] - for jfID, user := range t.app.storage.telegram { + for jfID, user := range t.app.storage.GetTelegram() { if user.ChatID == upd.Message.Chat.ID { user.Lang = sects[1] - t.app.storage.telegram[jfID] = user + t.app.storage.SetTelegramKey(jfID, user) if err := t.app.storage.storeTelegramUsers(); err != nil { t.app.err.Printf("Failed to store Telegram users: %v", err) } From 96c51af15ad6b2cf348a2ec5ef2391c3a8f3fa0c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 12:57:52 +0100 Subject: [PATCH 24/54] matrix: modularize --- ts/form.ts | 89 ++++++++++------------------------ ts/modules/account-linking.ts | 91 ++++++++++++++++++++++++++++++++++- ts/user.ts | 4 +- 3 files changed, 117 insertions(+), 67 deletions(-) diff --git a/ts/form.ts b/ts/form.ts index 59f4de2..3722c23 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -3,7 +3,7 @@ import { notificationBox, whichAnimationEvent } from "./modules/common.js"; import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; import { initValidator } from "./modules/validator.js"; -import { Discord, Telegram, ServiceConfiguration } from "./modules/account-linking.js"; +import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; interface formWindow extends Window { invalidPassword: string; @@ -56,7 +56,7 @@ if (window.telegramEnabled) { pin: window.telegramPIN, pinURL: "", verifiedURL: "/invite/" + window.code + "/telegram/verified/", - invalidCodeError: window.messages["errorInvalidCode"], + invalidCodeError: window.messages["errorInvalidPIN"], accountLinkedError: window.messages["errorAccountLinked"], successError: window.messages["verified"], successFunc: (modalClosed: boolean) => { @@ -88,7 +88,7 @@ if (window.discordEnabled) { inviteURL: window.discordInviteLink ? ("/invite/" + window.code + "/discord/invite") : "", pinURL: "", verifiedURL: "/invite/" + window.code + "/discord/verified/", - invalidCodeError: window.messages["errorInvalidCode"], + invalidCodeError: window.messages["errorInvalidPIN"], accountLinkedError: window.messages["errorAccountLinked"], successError: window.messages["verified"], successFunc: (modalClosed: boolean) => { @@ -114,69 +114,30 @@ var matrixPIN = ""; if (window.matrixEnabled) { window.matrixModal = new Modal(document.getElementById("modal-matrix"), window.matrixRequired); const matrixButton = document.getElementById("link-matrix") as HTMLSpanElement; - matrixButton.onclick = window.matrixModal.show; - const submitButton = document.getElementById("matrix-send") as HTMLSpanElement; - const input = document.getElementById("matrix-userid") as HTMLInputElement; - let userID = ""; - submitButton.onclick = () => { - addLoader(submitButton); - if (userID == "") { - const send = { - user_id: input.value - }; - _post("/invite/" + window.code + "/matrix/user", send, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - if (req.status == 400 && req.response["error"] == "errorAccountLinked") { - window.matrixModal.close(); - window.notifications.customError("accountLinkedError", window.messages["errorAccountLinked"]); - } - removeLoader(submitButton); - userID = input.value; - if (req.status != 200) { - window.notifications.customError("errorUnknown", window.messages["errorUnknown"]); - window.matrixModal.close(); - return; - } - submitButton.classList.add("~positive"); - submitButton.classList.remove("~info"); - setTimeout(() => { - submitButton.classList.add("~info"); - submitButton.classList.remove("~positive"); - }, 2000); - input.placeholder = "PIN"; - input.value = ""; - } - }); - } else { - _get("/invite/" + window.code + "/matrix/verified/" + userID + "/" + input.value, null, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - removeLoader(submitButton) - const valid = req.response["success"] as boolean; - if (valid) { - window.matrixModal.close(); - window.notifications.customPositive("successVerified", "", window.messages["verified"]); - matrixVerified = true; - matrixPIN = input.value; - matrixButton.classList.add("unfocused"); - document.getElementById("contact-via").classList.remove("unfocused"); - document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); - const radio = document.getElementById("contact-via-matrix") as HTMLInputElement; - radio.parentElement.classList.remove("unfocused"); - radio.checked = true; - validatorFunc(); - } else { - window.notifications.customError("errorInvalidPIN", window.messages["errorInvalidPIN"]); - submitButton.classList.add("~critical"); - submitButton.classList.remove("~info"); - setTimeout(() => { - submitButton.classList.add("~info"); - submitButton.classList.remove("~critical"); - }, 800); - } - } - },); + + const matrixConf: MatrixConfiguration = { + modal: window.matrixModal as Modal, + sendMessageURL: "/invite/" + window.code + "/matrix/user", + verifiedURL: "/invite/" + window.code + "/matrix/verified/", + invalidCodeError: window.messages["errorInvalidPIN"], + accountLinkedError: window.messages["errorAccountLinked"], + successError: window.messages["verified"], + successFunc: () => { + matrixVerified = true; + matrixPIN = matrix.pin; + matrixButton.classList.add("unfocused"); + document.getElementById("contact-via").classList.remove("unfocused"); + document.getElementById("contact-via-email").parentElement.classList.remove("unfocused"); + const radio = document.getElementById("contact-via-matrix") as HTMLInputElement; + radio.parentElement.classList.remove("unfocused"); + radio.checked = true; + validatorFunc(); } }; + + const matrix = new Matrix(matrixConf); + + matrixButton.onclick = () => { matrix.show(); }; } if (window.confirmation) { diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index 61bed1e..d14fcbb 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -1,5 +1,5 @@ import { Modal } from "../modules/modal.js"; -import { _get, _post, toggleLoader } from "../modules/common.js"; +import { _get, _post, toggleLoader, addLoader, removeLoader } from "../modules/common.js"; interface formWindow extends Window { invalidPassword: string; @@ -172,3 +172,92 @@ export class Telegram extends ServiceLinker { this._waiting = document.getElementById("telegram-waiting") as HTMLSpanElement; } }; + +export interface MatrixConfiguration { + modal: Modal; + sendMessageURL: string; + verifiedURL: string; + invalidCodeError: string; + accountLinkedError: string; + unknownError: string; + successError: string; + successFunc: () => void; +} + +export class Matrix { + private _conf: MatrixConfiguration; + private _verified = false; + private _name: string = "matrix"; + private _userID: string; + private _pin: string = ""; + private _input: HTMLInputElement; + private _submit: HTMLSpanElement; + + get verified(): boolean { return this._verified; } + get pin(): string { return this._pin; } + + constructor(conf: MatrixConfiguration) { + this._conf = conf; + this._input = document.getElementById("matrix-userid") as HTMLInputElement; + this._submit = document.getElementById("matrix-send") as HTMLSpanElement; + this._submit.onclick = () => { this._onclick(); }; + } + + private _onclick = () => { + addLoader(this._submit); + if (this._userID == "") { + this._sendMessage(); + } else { + this._verifyCode(); + } + }; + + show = () => { this._conf.modal.show(); } + + private _sendMessage = () => _post(this._conf.sendMessageURL, { "user_id": this._input.value }, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + removeLoader(this._submit); + if (req.status == 400 && req.response["error"] == "errorAccountLinked") { + this._conf.modal.close(); + window.notifications.customError("accountLinkedError", this._conf.accountLinkedError); + return; + } else if (req.status != 200) { + this._conf.modal.close(); + window.notifications.customError("unknownError", this._conf.unknownError); + return; + } + this._userID = this._input.value; + this._submit.classList.add("~positive"); + this._submit.classList.remove("~info"); + setTimeout(() => { + this._submit.classList.add("~info"); + this._submit.classList.remove("~positive"); + }, 2000); + this._input.placeholder = "PIN"; + this._input.value = ""; + }); + + private _verifyCode = () => _post(this._conf.verifiedURL + this._userID + "/" + this._input.value, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + removeLoader(this._submit); + const valid = req.response["success"] as boolean; + if (valid) { + this._conf.modal.close(); + window.notifications.customPositive(this._name + "Verified", "", this._conf.successError); + this._verified = true; + this._pin = this._input.value; + if (this._conf.successFunc) { + this._conf.successFunc(); + } + } else { + window.notifications.customError("invalidCodeError", this._conf.invalidCodeError); + this._submit.classList.add("~critical"); + this._submit.classList.remove("~info"); + setTimeout(() => { + this._submit.classList.add("~info"); + this._submit.classList.remove("~critical"); + }, 800); + } + }); +} + diff --git a/ts/user.ts b/ts/user.ts index bf621c1..7c334d8 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -291,7 +291,7 @@ const discordConf: ServiceConfiguration = { inviteURL: window.discordInviteLink ? "/my/discord/invite" : "", pinURL: "/my/pin/discord", verifiedURL: "/my/discord/verified/", - invalidCodeError: window.lang.notif("errorInvalidCode"), + invalidCodeError: window.lang.notif("errorInvalidPIN"), accountLinkedError: window.lang.notif("errorAccountLinked"), successError: window.lang.notif("verified"), successFunc: (modalClosed: boolean) => { @@ -306,7 +306,7 @@ const telegramConf: ServiceConfiguration = { pin: "", pinURL: "/my/pin/telegram", verifiedURL: "/my/telegram/verified/", - invalidCodeError: window.lang.notif("errorInvalidCode"), + invalidCodeError: window.lang.notif("errorInvalidPIN"), accountLinkedError: window.lang.notif("errorAccountLinked"), successError: window.lang.notif("verified"), successFunc: (modalClosed: boolean) => { From d509abdd5cf1f809e54362f42a37bb1755f564a1 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 13:28:13 +0100 Subject: [PATCH 25/54] userpage: add matrix --- api-userpage.go | 72 ++++++++++++++++++++++++++++++++++- router.go | 2 + storage.go | 4 ++ ts/form.ts | 1 + ts/modules/account-linking.ts | 9 +++-- ts/user.ts | 20 +++++++++- 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index b63199d..4a84949 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt" ) -// @Summary Returns the logged-in user's Jellyfin ID & Username. +// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details. // @Produce json // @Success 200 {object} MyDetailsDTO // @Router /my/details [get] @@ -379,3 +379,73 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser) respondBool(200, true, gc) } + +// @Summary Generate and send a new PIN to your given matrix user. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID." +// @Router /my/matrix/user [post] +// @tags User Page +func (app *appContext) MatrixSendMyPIN(gc *gin.Context) { + var req MatrixSendPINDTO + gc.BindJSON(&req) + if req.UserID == "" { + respond(400, "errorNoUserID", gc) + return + } + if app.config.Section("matrix").Key("require_unique").MustBool(false) { + for _, u := range app.storage.GetMatrix() { + if req.UserID == u.UserID { + respondBool(400, false, gc) + return + } + } + } + + ok := app.matrix.SendStart(req.UserID) + if !ok { + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + +// @Summary Check whether your matrix PIN is valid, and link the account to yours if so. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Param pin path string true "PIN code to check" +// @Param invCode path string true "invite Code" +// @Param userID path string true "Matrix User ID" +// @Router /my/matrix/verified/{userID}/{pin} [get] +// @tags User Page +func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) { + userID := gc.Param("userID") + pin := gc.Param("pin") + user, ok := app.matrix.tokens[pin] + if !ok { + app.debug.Println("Matrix: PIN not found") + respondBool(200, false, gc) + return + } + if user.User.UserID != userID { + app.debug.Println("Matrix: User ID of PIN didn't match") + respondBool(200, false, gc) + return + } + + mxUser := *user.User + mxUser.Contact = true + existingUser, ok := app.storage.GetMatrixKey(gc.GetString("jfId")) + if ok { + mxUser.Lang = existingUser.Lang + mxUser.Contact = existingUser.Contact + } + + app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser) + delete(app.matrix.tokens, pin) + respondBool(200, true, gc) +} diff --git a/router.go b/router.go index b1b5b95..18e3814 100644 --- a/router.go +++ b/router.go @@ -235,6 +235,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.GET(p+"/pin/:service", app.GetMyPIN) user.GET(p+"/discord/verified/:pin", app.MyDiscordVerifiedInvite) user.GET(p+"/telegram/verified/:pin", app.MyTelegramVerifiedInvite) + user.POST(p+"/matrix/user", app.MatrixSendMyPIN) + user.POST(p+"/matrix/verified/:userID/:pin", app.MatrixCheckMyPIN) } } } diff --git a/storage.go b/storage.go index 605516e..d24b960 100644 --- a/storage.go +++ b/storage.go @@ -63,6 +63,7 @@ func (st *Storage) SetEmailsKey(k string, v EmailAddress) { func (st *Storage) DeleteEmailsKey(k string) { st.emailsLock.Lock() delete(st.emails, k) + st.storeEmails() st.emailsLock.Unlock() } @@ -89,6 +90,7 @@ func (st *Storage) SetDiscordKey(k string, v DiscordUser) { func (st *Storage) DeleteDiscordKey(k string) { st.discordLock.Lock() delete(st.discord, k) + st.storeDiscordUsers() st.discordLock.Unlock() } @@ -115,6 +117,7 @@ func (st *Storage) SetTelegramKey(k string, v TelegramUser) { func (st *Storage) DeleteTelegramKey(k string) { st.telegramLock.Lock() delete(st.telegram, k) + st.storeTelegramUsers() st.telegramLock.Unlock() } @@ -141,6 +144,7 @@ func (st *Storage) SetMatrixKey(k string, v MatrixUser) { func (st *Storage) DeleteMatrixKey(k string) { st.matrixLock.Lock() delete(st.matrix, k) + st.storeMatrixUsers() st.matrixLock.Unlock() } diff --git a/ts/form.ts b/ts/form.ts index 3722c23..961de8a 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -121,6 +121,7 @@ if (window.matrixEnabled) { verifiedURL: "/invite/" + window.code + "/matrix/verified/", invalidCodeError: window.messages["errorInvalidPIN"], accountLinkedError: window.messages["errorAccountLinked"], + unknownError: window.messages["errorUnknown"], successError: window.messages["verified"], successFunc: () => { matrixVerified = true; diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index d14fcbb..ea3994f 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -188,7 +188,7 @@ export class Matrix { private _conf: MatrixConfiguration; private _verified = false; private _name: string = "matrix"; - private _userID: string; + private _userID: string = ""; private _pin: string = ""; private _input: HTMLInputElement; private _submit: HTMLSpanElement; @@ -212,7 +212,10 @@ export class Matrix { } }; - show = () => { this._conf.modal.show(); } + show = () => { + this._input.value = ""; + this._conf.modal.show(); + } private _sendMessage = () => _post(this._conf.sendMessageURL, { "user_id": this._input.value }, (req: XMLHttpRequest) => { if (req.readyState != 4) return; @@ -258,6 +261,6 @@ export class Matrix { this._submit.classList.remove("~critical"); }, 800); } - }); + }, true); } diff --git a/ts/user.ts b/ts/user.ts index 7c334d8..1983d66 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -3,7 +3,7 @@ import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; -import { Discord, Telegram, ServiceConfiguration } from "./modules/account-linking.js"; +import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; interface userWindow extends Window { jellyfinID: string; @@ -316,6 +316,22 @@ const telegramConf: ServiceConfiguration = { let telegram = new Telegram(telegramConf); +const matrixConf: MatrixConfiguration = { + modal: window.modals.matrix as Modal, + sendMessageURL: "/my/matrix/user", + verifiedURL: "/my/matrix/verified/", + invalidCodeError: window.lang.notif("errorInvalidPIN"), + accountLinkedError: window.lang.notif("errorAccountLinked"), + unknownError: window.lang.notif("errorUnknown"), + successError: window.lang.notif("verified"), + successFunc: () => { + setTimeout(() => window.location.reload(), 1200); + } +}; + +let matrix = new Matrix(matrixConf); + + document.addEventListener("details-reload", () => { _get("/my/details", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { @@ -347,7 +363,7 @@ document.addEventListener("details-reload", () => { {name: "email", icon: ``, f: addEditEmail}, {name: "discord", icon: ``, f: (add: boolean) => { discord.onclick(); }}, {name: "telegram", icon: ``, f: (add: boolean) => { telegram.onclick() }}, - {name: "matrix", icon: `[m]`, f: null} + {name: "matrix", icon: `[m]`, f: (add: boolean) => { matrix.show(); }} ]; for (let method of contactMethods) { From 8e153cd92f8689dd398966997283515e00c0ea3b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 16:44:12 +0100 Subject: [PATCH 26/54] userpage: unlink accounts --- api-userpage.go | 30 ++++++++++++++++++++++++++++++ lang/admin/da-dk.json | 1 - lang/admin/de-de.json | 1 - lang/admin/el-gr.json | 1 - lang/admin/en-gb.json | 1 - lang/admin/en-us.json | 1 - lang/admin/es-es.json | 1 - lang/admin/fr-fr.json | 1 - lang/admin/hu-hu.json | 1 - lang/admin/id-id.json | 1 - lang/admin/nl-nl.json | 1 - lang/admin/pl-pl.json | 1 - lang/admin/pt-br.json | 1 - lang/admin/sv-se.json | 1 - lang/admin/vi-vn.json | 1 - lang/admin/zh-hans.json | 1 - lang/admin/zh-hant.json | 1 - lang/common/da-dk.json | 3 ++- lang/common/de-de.json | 3 ++- lang/common/el-gr.json | 3 ++- lang/common/en-gb.json | 3 ++- lang/common/en-us.json | 7 +++---- lang/common/es-es.json | 3 ++- lang/common/fr-fr.json | 3 ++- lang/common/hu-hu.json | 3 ++- lang/common/id-id.json | 3 ++- lang/common/nl-nl.json | 3 ++- lang/common/pt-br.json | 3 ++- lang/common/sv-se.json | 3 ++- lang/common/vi-vn.json | 3 ++- lang/common/zh-hans.json | 3 ++- lang/common/zh-hant.json | 3 ++- lang/form/en-us.json | 2 +- router.go | 3 +++ scripts/langmover/common.json | 5 +++-- ts/user.ts | 35 +++++++++++++++++++++++++---------- 36 files changed, 93 insertions(+), 47 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index 4a84949..718741a 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -449,3 +449,33 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) { delete(app.matrix.tokens, pin) respondBool(200, true, gc) } + +// @Summary unlink the Discord account from your Jellyfin user. Always succeeds. +// @Produce json +// @Success 200 {object} boolResponse +// @Router /my/discord [delete] +// @Tags Users +func (app *appContext) UnlinkMyDiscord(gc *gin.Context) { + app.storage.DeleteDiscordKey(gc.GetString("jfId")) + respondBool(200, true, gc) +} + +// @Summary unlink the Telegram account from your Jellyfin user. Always succeeds. +// @Produce json +// @Success 200 {object} boolResponse +// @Router /my/telegram [delete] +// @Tags Users +func (app *appContext) UnlinkMyTelegram(gc *gin.Context) { + app.storage.DeleteTelegramKey(gc.GetString("jfId")) + respondBool(200, true, gc) +} + +// @Summary unlink the Matrix account from your Jellyfin user. Always succeeds. +// @Produce json +// @Success 200 {object} boolResponse +// @Router /my/matrix [delete] +// @Tags Users +func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { + app.storage.DeleteMatrixKey(gc.GetString("jfId")) + respondBool(200, true, gc) +} diff --git a/lang/admin/da-dk.json b/lang/admin/da-dk.json index 3b117ce..da0e967 100644 --- a/lang/admin/da-dk.json +++ b/lang/admin/da-dk.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "Send til", "create": "Opret", "apply": "Anvend", - "delete": "Slet", "select": "Vælg", "name": "Navn", "date": "Dato", diff --git a/lang/admin/de-de.json b/lang/admin/de-de.json index 1004d5c..bbcf259 100644 --- a/lang/admin/de-de.json +++ b/lang/admin/de-de.json @@ -15,7 +15,6 @@ "inviteSendToEmail": "Senden an", "create": "Erstellen", "apply": "Anwenden", - "delete": "Löschen", "name": "Name", "date": "Datum", "lastActiveTime": "Zuletzt aktiv", diff --git a/lang/admin/el-gr.json b/lang/admin/el-gr.json index dc358df..e84f19b 100644 --- a/lang/admin/el-gr.json +++ b/lang/admin/el-gr.json @@ -15,7 +15,6 @@ "inviteSendToEmail": "Αποστολή σε", "create": "Δημιουργία", "apply": "Εφαρμογή", - "delete": "Διαγραφή", "name": "Όνομα", "date": "Ημερομηνία", "lastActiveTime": "Τελευταία Ενεργός", diff --git a/lang/admin/en-gb.json b/lang/admin/en-gb.json index 44a3cf8..d716caa 100644 --- a/lang/admin/en-gb.json +++ b/lang/admin/en-gb.json @@ -69,7 +69,6 @@ "inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively", "inviteSendToEmail": "Send to", "apply": "Apply", - "delete": "Delete", "updates": "Updates", "variables": "Variables", "preview": "Preview", diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index c1eb7e4..05b083d 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "Send to", "create": "Create", "apply": "Apply", - "delete": "Delete", "select": "Select", "name": "Name", "date": "Date", diff --git a/lang/admin/es-es.json b/lang/admin/es-es.json index 6e41364..1506e76 100644 --- a/lang/admin/es-es.json +++ b/lang/admin/es-es.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "Enviar a", "create": "Crear", "apply": "Aplicar", - "delete": "Eliminar", "name": "Nombre", "date": "Fecha", "updates": "Actualizaciones", diff --git a/lang/admin/fr-fr.json b/lang/admin/fr-fr.json index 2a5809e..cb8eb5a 100644 --- a/lang/admin/fr-fr.json +++ b/lang/admin/fr-fr.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "Envoyer à", "create": "Créer", "apply": "Appliquer", - "delete": "Effacer", "name": "Nom", "date": "Date", "lastActiveTime": "Dernière activité", diff --git a/lang/admin/hu-hu.json b/lang/admin/hu-hu.json index 9ad59ed..4161ef0 100644 --- a/lang/admin/hu-hu.json +++ b/lang/admin/hu-hu.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "Címzett", "create": "Létrehozás", "apply": "Alkalmaz", - "delete": "Törlés", "select": "Kiválasztás", "name": "Név", "date": "Dátum", diff --git a/lang/admin/id-id.json b/lang/admin/id-id.json index 71a1869..5957396 100644 --- a/lang/admin/id-id.json +++ b/lang/admin/id-id.json @@ -15,7 +15,6 @@ "inviteSendToEmail": "Dikirim kepada", "create": "Buat", "apply": "Terapkan", - "delete": "Hapus", "name": "Nama", "date": "Tanggal", "lastActiveTime": "Terakhir Aktif", diff --git a/lang/admin/nl-nl.json b/lang/admin/nl-nl.json index 0464d96..46b9452 100644 --- a/lang/admin/nl-nl.json +++ b/lang/admin/nl-nl.json @@ -15,7 +15,6 @@ "inviteSendToEmail": "Stuur naar", "create": "Aanmaken", "apply": "Toepassen", - "delete": "Verwijderen", "name": "Naam", "date": "Datum", "lastActiveTime": "Laatst actief", diff --git a/lang/admin/pl-pl.json b/lang/admin/pl-pl.json index 522fb02..6b1bdc2 100644 --- a/lang/admin/pl-pl.json +++ b/lang/admin/pl-pl.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "", "create": "", "apply": "", - "delete": "", "select": "", "name": "Imię", "date": "Data", diff --git a/lang/admin/pt-br.json b/lang/admin/pt-br.json index 2073940..0310937 100644 --- a/lang/admin/pt-br.json +++ b/lang/admin/pt-br.json @@ -15,7 +15,6 @@ "inviteSendToEmail": "Enviar para", "create": "Criar", "apply": "Aplicar", - "delete": "Deletar", "name": "Nome", "date": "Data", "lastActiveTime": "Ativo pela última vez", diff --git a/lang/admin/sv-se.json b/lang/admin/sv-se.json index 60dd719..1f7e1f1 100644 --- a/lang/admin/sv-se.json +++ b/lang/admin/sv-se.json @@ -15,7 +15,6 @@ "inviteSendToEmail": "Skicka till", "create": "Skapa", "apply": "Tillämpa", - "delete": "Radera", "name": "Namn", "date": "Datum", "lastActiveTime": "Senast aktiv", diff --git a/lang/admin/vi-vn.json b/lang/admin/vi-vn.json index 6366e28..7dd7e56 100644 --- a/lang/admin/vi-vn.json +++ b/lang/admin/vi-vn.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "Gửi tới", "create": "Tạo mới", "apply": "Áp dụng", - "delete": "Xóa", "select": "Chọn", "name": "Tên", "date": "Ngày", diff --git a/lang/admin/zh-hans.json b/lang/admin/zh-hans.json index 98d050f..6190c8f 100644 --- a/lang/admin/zh-hans.json +++ b/lang/admin/zh-hans.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "发送到", "create": "创建", "apply": "申请", - "delete": "删除", "select": "选择", "name": "名称", "date": "日期", diff --git a/lang/admin/zh-hant.json b/lang/admin/zh-hant.json index d876316..5546e09 100644 --- a/lang/admin/zh-hant.json +++ b/lang/admin/zh-hant.json @@ -17,7 +17,6 @@ "inviteSendToEmail": "發送到", "create": "創建", "apply": "應用", - "delete": "刪除", "select": "選擇", "name": "帳戶名稱", "date": "日期", diff --git a/lang/common/da-dk.json b/lang/common/da-dk.json index b2c3f3f..ce3fb1d 100644 --- a/lang/common/da-dk.json +++ b/lang/common/da-dk.json @@ -34,7 +34,8 @@ "disable": "Deaktiver", "expiry": "Udløb", "add": "Tilføj", - "edit": "Rediger" + "edit": "Rediger", + "delete": "Slet" }, "notifications": { "errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.", diff --git a/lang/common/de-de.json b/lang/common/de-de.json index 339ce88..6459eef 100644 --- a/lang/common/de-de.json +++ b/lang/common/de-de.json @@ -34,7 +34,8 @@ "disable": "Deaktivieren", "expiry": "Ablaufdatum", "add": "Hinzufügen", - "edit": "Bearbeiten" + "edit": "Bearbeiten", + "delete": "Löschen" }, "notifications": { "errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.", diff --git a/lang/common/el-gr.json b/lang/common/el-gr.json index 7826ab7..3c0c5c9 100644 --- a/lang/common/el-gr.json +++ b/lang/common/el-gr.json @@ -24,7 +24,8 @@ "reEnable": "Επανα-ενεργοποίηση", "disable": "Απενεργοποίηση", "expiry": "Λήξη", - "edit": "Επεξεργασία" + "edit": "Επεξεργασία", + "delete": "Διαγραφή" }, "notifications": { "errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.", diff --git a/lang/common/en-gb.json b/lang/common/en-gb.json index bb44536..ff6147b 100644 --- a/lang/common/en-gb.json +++ b/lang/common/en-gb.json @@ -34,7 +34,8 @@ "disable": "Disable", "expiry": "Expiry", "add": "Add", - "edit": "Edit" + "edit": "Edit", + "delete": "Delete" }, "notifications": { "errorLoginBlank": "The username and/or password was left blank.", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 3102ea3..688b5a2 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -33,13 +33,12 @@ "reEnable": "Re-enable", "disable": "Disable", "contactMethods": "Contact Methods", - "addContactMethod": "Add Contact Method", - "editContactMethod": "Edit Contact Method", "accountStatus": "Account Status", "notSet": "Not set", "expiry": "Expiry", "add": "Add", - "edit": "Edit" + "edit": "Edit", + "delete": "Delete" }, "notifications": { "errorLoginBlank": "The username and/or password were left blank.", @@ -62,4 +61,4 @@ "plural": "{n} Days" } } -} +} \ No newline at end of file diff --git a/lang/common/es-es.json b/lang/common/es-es.json index b446d47..f0bec53 100644 --- a/lang/common/es-es.json +++ b/lang/common/es-es.json @@ -34,7 +34,8 @@ "disable": "Desactivar", "expiry": "Expiración", "add": "Agregar", - "edit": "Editar" + "edit": "Editar", + "delete": "Eliminar" }, "notifications": { "errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.", diff --git a/lang/common/fr-fr.json b/lang/common/fr-fr.json index 193ef1f..d7dfd5c 100644 --- a/lang/common/fr-fr.json +++ b/lang/common/fr-fr.json @@ -34,7 +34,8 @@ "disable": "Désactivé", "expiry": "Expiration", "add": "Ajouter", - "edit": "Éditer" + "edit": "Éditer", + "delete": "Effacer" }, "notifications": { "errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.", diff --git a/lang/common/hu-hu.json b/lang/common/hu-hu.json index c4323a9..5c68b40 100644 --- a/lang/common/hu-hu.json +++ b/lang/common/hu-hu.json @@ -12,7 +12,8 @@ "disable": "Letiltás", "expiry": "Lejárat", "add": "Hozzáadás", - "edit": "Szerkesztés" + "edit": "Szerkesztés", + "delete": "Törlés" }, "notifications": {}, "quantityStrings": {} diff --git a/lang/common/id-id.json b/lang/common/id-id.json index a925667..e8f4e9b 100644 --- a/lang/common/id-id.json +++ b/lang/common/id-id.json @@ -18,7 +18,8 @@ "theme": "Tema", "login": "Masuk", "logout": "Keluar", - "edit": "Edit" + "edit": "Edit", + "delete": "Hapus" }, "notifications": { "errorLoginBlank": "Nama pengguna dan / atau sandi kosong.", diff --git a/lang/common/nl-nl.json b/lang/common/nl-nl.json index b43329a..0591de1 100644 --- a/lang/common/nl-nl.json +++ b/lang/common/nl-nl.json @@ -34,7 +34,8 @@ "disable": "Uitschakelen", "expiry": "Verloop", "add": "Voeg toe", - "edit": "Bewerken" + "edit": "Bewerken", + "delete": "Verwijderen" }, "notifications": { "errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.", diff --git a/lang/common/pt-br.json b/lang/common/pt-br.json index 5ed9ae1..b7953fa 100644 --- a/lang/common/pt-br.json +++ b/lang/common/pt-br.json @@ -34,7 +34,8 @@ "disable": "Desativar", "expiry": "Expira", "add": "Adicionar", - "edit": "Editar" + "edit": "Editar", + "delete": "Deletar" }, "notifications": { "errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.", diff --git a/lang/common/sv-se.json b/lang/common/sv-se.json index 554ae2c..0eb8b5f 100644 --- a/lang/common/sv-se.json +++ b/lang/common/sv-se.json @@ -21,7 +21,8 @@ "enabled": "Aktiverad", "disabled": "Inaktiverad", "expiry": "Löper ut", - "edit": "Redigera" + "edit": "Redigera", + "delete": "Radera" }, "notifications": { "errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.", diff --git a/lang/common/vi-vn.json b/lang/common/vi-vn.json index dce2ccb..3a8abbd 100644 --- a/lang/common/vi-vn.json +++ b/lang/common/vi-vn.json @@ -12,7 +12,8 @@ "disable": "Tắt", "expiry": "Hết hạn", "add": "Thêm", - "edit": "Chỉnh sửa" + "edit": "Chỉnh sửa", + "delete": "Xóa" }, "notifications": { "errorConnection": "Không thể kết nối với jfa-go.", diff --git a/lang/common/zh-hans.json b/lang/common/zh-hans.json index 1c6daa7..2d12e74 100644 --- a/lang/common/zh-hans.json +++ b/lang/common/zh-hans.json @@ -34,7 +34,8 @@ "disable": "禁用", "expiry": "到期", "add": "添加", - "edit": "编辑" + "edit": "编辑", + "delete": "删除" }, "notifications": { "errorLoginBlank": "用户名/密码留空。", diff --git a/lang/common/zh-hant.json b/lang/common/zh-hant.json index a4fe3a6..8b0b42f 100644 --- a/lang/common/zh-hant.json +++ b/lang/common/zh-hant.json @@ -34,7 +34,8 @@ "disable": "禁用", "expiry": "到期", "add": "添加", - "edit": "編輯" + "edit": "編輯", + "delete": "刪除" }, "notifications": { "errorLoginBlank": "帳戶名稱和/或密碼留空。", diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 1e132b3..d41904c 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -61,4 +61,4 @@ "plural": "Must have at least {n} special characters" } } -} +} \ No newline at end of file diff --git a/router.go b/router.go index 18e3814..bf9bf99 100644 --- a/router.go +++ b/router.go @@ -237,6 +237,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.GET(p+"/telegram/verified/:pin", app.MyTelegramVerifiedInvite) user.POST(p+"/matrix/user", app.MatrixSendMyPIN) user.POST(p+"/matrix/verified/:userID/:pin", app.MatrixCheckMyPIN) + user.DELETE(p+"/discord", app.UnlinkMyDiscord) + user.DELETE(p+"/telegram", app.UnlinkMyTelegram) + user.DELETE(p+"/matrix", app.UnlinkMyMatrix) } } } diff --git a/scripts/langmover/common.json b/scripts/langmover/common.json index b04d430..3d078d3 100644 --- a/scripts/langmover/common.json +++ b/scripts/langmover/common.json @@ -36,8 +36,9 @@ "accountStatus": "common", "notSet": "common", "expiry": "common", - "add": "admin", - "edit": "admin" + "add": "common", + "edit": "common", + "delete": "admin" }, "notifications": { "errorLoginBlank": "common", diff --git a/ts/user.ts b/ts/user.ts index 1983d66..39d9a8c 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -1,7 +1,7 @@ import { ThemeManager } from "./modules/theme.js"; import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; -import { _get, _post, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; +import { _get, _post, _delete, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; @@ -92,7 +92,7 @@ class ContactMethods { this._buttons = {}; } - append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void) => { + append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => { const row = document.createElement("div"); row.classList.add("row", "flex-expand", "my-2"); let innerHTML = ` @@ -118,6 +118,15 @@ class ContactMethods { `; } + + if (!required && details.value != "") { + innerHTML += ` + + `; + } + innerHTML += `
`; @@ -159,6 +168,14 @@ class ContactMethods { const addEditButton = row.querySelector(".user-contact-edit") as HTMLButtonElement; addEditButton.onclick = () => addEditFunc(details.value == ""); } + + if (!required && details.value != "") { + const deleteButton = row.querySelector(".user-contact-delete") as HTMLButtonElement; + deleteButton.onclick = () => _delete("/my/" + name, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + window.location.reload(); + }); + } this._content.appendChild(row); }; @@ -248,8 +265,6 @@ class ExpiryCard { this._interval = window.setInterval(this._drawCountdown, 60*1000); this._drawCountdown(); } - - } var expiryCard = new ExpiryCard(statusCard); @@ -359,16 +374,16 @@ document.addEventListener("details-reload", () => { // Note the weird format of the functions for discord/telegram: // "this" was being redefined within the onclick() method, so // they had to be wrapped in an anonymous function. - const contactMethods: { name: string, icon: string, f: (add: boolean) => void }[] = [ - {name: "email", icon: ``, f: addEditEmail}, - {name: "discord", icon: ``, f: (add: boolean) => { discord.onclick(); }}, - {name: "telegram", icon: ``, f: (add: boolean) => { telegram.onclick() }}, - {name: "matrix", icon: `[m]`, f: (add: boolean) => { matrix.show(); }} + const contactMethods: { name: string, icon: string, f: (add: boolean) => void, required: boolean }[] = [ + {name: "email", icon: ``, f: addEditEmail, required: true}, + {name: "discord", icon: ``, f: (add: boolean) => { discord.onclick(); }, required: window.discordRequired}, + {name: "telegram", icon: ``, f: (add: boolean) => { telegram.onclick() }, required: window.telegramRequired}, + {name: "matrix", icon: `[m]`, f: (add: boolean) => { matrix.show(); }, required: window.matrixRequired} ]; for (let method of contactMethods) { if (method.name in details) { - contactMethodList.append(method.name, details[method.name], method.icon, method.f); + contactMethodList.append(method.name, details[method.name], method.icon, method.f, method.required); } } From 7b9b0d8a84acbe3a059a37d3c639221dbbf332b4 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 21:43:25 +0100 Subject: [PATCH 27/54] userpage: implement login message card Shares code with custom emails, so most related functions have had a %s/Email/Message/g. Press the edit button on the user page setting to add a message. --- api-messages.go | 105 +++++++++++++++++++++++++++++----------- config.go | 8 ++- config/config-base.json | 8 +++ css/base.css | 2 + email.go | 4 +- html/header.html | 2 +- html/login-modal.html | 27 +++++++---- html/user.html | 2 +- lang/admin/en-us.json | 6 ++- lang/common/en-us.json | 2 +- lang/form/en-us.json | 4 +- matrix.go | 2 +- router.go | 8 +-- storage.go | 70 ++++++++++++++++----------- ts/modules/settings.ts | 14 +++--- views.go | 19 ++++++++ 16 files changed, 197 insertions(+), 86 deletions(-) diff --git a/api-messages.go b/api-messages.go index f971717..dcaecc4 100644 --- a/api-messages.go +++ b/api-messages.go @@ -15,12 +15,16 @@ import ( // @Router /config/emails [get] // @Security Bearer // @tags Configuration -func (app *appContext) GetCustomEmails(gc *gin.Context) { +func (app *appContext) GetCustomContent(gc *gin.Context) { lang := gc.Query("lang") if _, ok := app.storage.lang.Email[lang]; !ok { lang = app.storage.lang.chosenEmailLang } - gc.JSON(200, emailListDTO{ + adminLang := lang + if _, ok := app.storage.lang.Admin[lang]; !ok { + adminLang = app.storage.lang.chosenAdminLang + } + list := emailListDTO{ "UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled}, "InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled}, "PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled}, @@ -31,13 +35,25 @@ func (app *appContext) GetCustomEmails(gc *gin.Context) { "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled}, "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled}, - }) + "UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.userPage.Login.Enabled}, + "UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.userPage.Page.Enabled}, + } + + filter := gc.Query("filter") + if filter == "user" { + list = emailListDTO{"UserLogin": list["UserLogin"], "UserPage": list["UserPage"]} + } else { + delete(list, "UserLogin") + delete(list, "UserPage") + } + + gc.JSON(200, list) } -func (app *appContext) getCustomEmail(id string) *customEmail { +func (app *appContext) getCustomMessage(id string) *customContent { switch id { case "Announcement": - return &customEmail{} + return &customContent{} case "UserCreated": return &app.storage.customEmails.UserCreated case "InviteExpiry": @@ -58,13 +74,17 @@ func (app *appContext) getCustomEmail(id string) *customEmail { return &app.storage.customEmails.EmailConfirmation case "UserExpired": return &app.storage.customEmails.UserExpired + case "UserLogin": + return &app.storage.userPage.Login + case "UserPage": + return &app.storage.userPage.Page } return nil } // @Summary Sets the corresponding custom email. // @Produce json -// @Param customEmail body customEmail true "Content = email (in markdown)." +// @Param customEmails body customEmails true "Content = email (in markdown)." // @Success 200 {object} boolResponse // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse @@ -72,25 +92,29 @@ func (app *appContext) getCustomEmail(id string) *customEmail { // @Router /config/emails/{id} [post] // @Security Bearer // @tags Configuration -func (app *appContext) SetCustomEmail(gc *gin.Context) { - var req customEmail +func (app *appContext) SetCustomMessage(gc *gin.Context) { + var req customContent gc.BindJSON(&req) id := gc.Param("id") if req.Content == "" { respondBool(400, false, gc) return } - email := app.getCustomEmail(id) - if email == nil { + message := app.getCustomMessage(id) + if message == nil { respondBool(400, false, gc) return } - email.Content = req.Content - email.Enabled = true + message.Content = req.Content + message.Enabled = true if app.storage.storeCustomEmails() != nil { respondBool(500, false, gc) return } + if app.storage.storeUserPageContent() != nil { + respondBool(500, false, gc) + return + } respondBool(200, true, gc) } @@ -104,7 +128,7 @@ func (app *appContext) SetCustomEmail(gc *gin.Context) { // @Router /config/emails/{id}/state/{enable/disable} [post] // @Security Bearer // @tags Configuration -func (app *appContext) SetCustomEmailState(gc *gin.Context) { +func (app *appContext) SetCustomMessageState(gc *gin.Context) { id := gc.Param("id") s := gc.Param("state") enabled := false @@ -113,20 +137,24 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) { } else if s != "disable" { respondBool(400, false, gc) } - email := app.getCustomEmail(id) - if email == nil { + message := app.getCustomMessage(id) + if message == nil { respondBool(400, false, gc) return } - email.Enabled = enabled + message.Enabled = enabled if app.storage.storeCustomEmails() != nil { respondBool(500, false, gc) return } + if app.storage.storeUserPageContent() != nil { + respondBool(500, false, gc) + return + } respondBool(200, true, gc) } -// @Summary Returns the custom email (generating it if not set) and list of used variables in it. +// @Summary Returns the custom email/message (generating it if not set) and list of used variables in it. // @Produce json // @Success 200 {object} customEmailDTO // @Failure 400 {object} boolResponse @@ -135,7 +163,7 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) { // @Router /config/emails/{id} [get] // @Security Bearer // @tags Configuration -func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { +func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { lang := app.storage.lang.chosenEmailLang id := gc.Param("id") var content string @@ -146,20 +174,26 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { var values map[string]interface{} username := app.storage.lang.Email[lang].Strings.get("username") emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress") - email := app.getCustomEmail(id) - if email == nil { - app.err.Printf("Failed to get custom email with ID \"%s\"", id) + customMessage := app.getCustomMessage(id) + if customMessage == nil { + app.err.Printf("Failed to get custom message with ID \"%s\"", id) respondBool(400, false, gc) return } if id == "WelcomeEmail" { conditionals = []string{"{yourAccountWillExpire}"} - email.Conditionals = conditionals + customMessage.Conditionals = conditionals + } else if id == "UserPage" { + variables = []string{"{username}"} + customMessage.Variables = variables + } else if id == "UserLogin" { + variables = []string{} + customMessage.Variables = variables } - content = email.Content + content = customMessage.Content noContent := content == "" if !noContent { - variables = email.Variables + variables = customMessage.Variables } switch id { case "Announcement": @@ -215,12 +249,14 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { msg, err = app.email.constructUserExpired(app, true) } values = app.email.userExpiredValues(app, false) + case "UserLogin", "UserPage": + values = map[string]interface{}{} } if err != nil { respondBool(500, false, gc) return } - if noContent && id != "Announcement" { + if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" { content = msg.Text variables = make([]string, strings.Count(content, "{")) i := 0 @@ -239,7 +275,7 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { i++ } } - email.Variables = variables + customMessage.Variables = variables } if variables == nil { variables = []string{} @@ -248,10 +284,21 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { respondBool(500, false, gc) return } - mail, err := app.email.constructTemplate("", "
", app) - if err != nil { + if app.storage.storeUserPageContent() != nil { respondBool(500, false, gc) - return + } + var mail *Message + if id != "UserLogin" && id != "UserPage" { + mail, err = app.email.constructTemplate("", "
", app) + if err != nil { + respondBool(500, false, gc) + return + } + } else { + mail = &Message{ + HTML: "
", + Markdown: "
", + } } gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text}) } diff --git a/config.go b/config.go index a4316ab..7fb388c 100644 --- a/config.go +++ b/config.go @@ -46,7 +46,7 @@ func (app *appContext) loadConfig() error { key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) } } - for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements"} { + for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) } for _, key := range []string{"matrix_sql"} { @@ -160,6 +160,12 @@ func (app *appContext) loadConfig() error { app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String() app.storage.loadCustomEmails() + app.MustSetValue("user_page", "enabled", "true") + if app.config.Section("user_page").Key("enabled").MustBool(false) { + app.storage.userPage_path = app.config.Section("files").Key("custom_user_page_content").String() + app.storage.loadUserPageContent() + } + substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("") if substituteStrings != "" { diff --git a/config/config-base.json b/config/config-base.json index 20b6e7b..2a77336 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -1551,6 +1551,14 @@ "value": "", "description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info." }, + "custom_user_page_content": { + "name": "Custom user page content", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "JSON file generated by program in settings, containing user page messages. See wiki for more info." + }, "telegram_users": { "name": "Telegram users", "required": false, diff --git a/css/base.css b/css/base.css index 5358312..5599eb3 100644 --- a/css/base.css +++ b/css/base.css @@ -13,6 +13,8 @@ --border-width-2: 3px; --border-width-4: 5px; --border-width-8: 8px; + + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } .light { diff --git a/email.go b/email.go index 808369c..777ed25 100644 --- a/email.go +++ b/email.go @@ -24,7 +24,7 @@ import ( sMail "github.com/xhit/go-simple-mail/v2" ) -var renderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) +var markdownRenderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) // EmailClient implements email sending, right now via smtp, mailgun or a dummy client. type EmailClient interface { @@ -353,7 +353,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, u subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]}) } email := &Message{Subject: subject} - html := markdown.ToHTML([]byte(md), nil, renderer) + html := markdown.ToHTML([]byte(md), nil, markdownRenderer) text := stripMarkdown(md) message := app.config.Section("messages").Key("message").String() var err error diff --git a/html/header.html b/html/header.html index 1babab1..db2cdab 100644 --- a/html/header.html +++ b/html/header.html @@ -1,4 +1,4 @@ - + diff --git a/html/login-modal.html b/html/login-modal.html index f640f04..3226d9e 100644 --- a/html/login-modal.html +++ b/html/login-modal.html @@ -1,11 +1,20 @@ diff --git a/html/user.html b/html/user.html index 609f7c0..b14c940 100644 --- a/html/user.html +++ b/html/user.html @@ -43,6 +43,7 @@ + {{ template "login-modal.html" . }} {{ template "account-linking.html" . }}
@@ -68,7 +69,6 @@ {{ .strings.logout }}
- {{ template "login-modal.html" . }}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 05b083d..9bc51e1 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -113,7 +113,9 @@ "actions": "Actions", "searchOptions": "Search Options", "matchText": "Match Text", - "jellyfinID": "Jellyfin ID" + "jellyfinID": "Jellyfin ID", + "userPageLogin": "User Page: Login", + "userPagePage": "User Page: Page" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -209,4 +211,4 @@ "plural": "Extended expiry for {n} users." } } -} \ No newline at end of file +} diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 688b5a2..266216f 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -61,4 +61,4 @@ "plural": "{n} Days" } } -} \ No newline at end of file +} diff --git a/lang/form/en-us.json b/lang/form/en-us.json index d41904c..652856e 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -21,6 +21,8 @@ "sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below.", "matrixEnterUser": "Enter your User ID, press submit, and a PIN will be sent to you. Enter it here to continue.", "welcomeUser": "Welcome, {user}!", + "addContactMethod": "Add Contact Method", + "editContactMethod": "Edit Contact Method", "joinTheServer": "Join the server:" }, "notifications": { @@ -61,4 +63,4 @@ "plural": "Must have at least {n} special characters" } } -} \ No newline at end of file +} diff --git a/matrix.go b/matrix.go index 932a6c0..9935a2c 100644 --- a/matrix.go +++ b/matrix.go @@ -249,7 +249,7 @@ func (d *MatrixDaemon) Send(message *Message, users ...MatrixUser) (err error) { md := "" if message.Markdown != "" { // Convert images to links - md = string(markdown.ToHTML([]byte(strings.ReplaceAll(message.Markdown, "![", "[")), nil, renderer)) + md = string(markdown.ToHTML([]byte(strings.ReplaceAll(message.Markdown, "![", "[")), nil, markdownRenderer)) } content := &event.MessageEventContent{ MsgType: "m.text", diff --git a/router.go b/router.go index bf9bf99..72faa01 100644 --- a/router.go +++ b/router.go @@ -196,10 +196,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/config/update", app.CheckUpdate) api.POST(p+"/config/update", app.ApplyUpdate) - api.GET(p+"/config/emails", app.GetCustomEmails) - api.GET(p+"/config/emails/:id", app.GetCustomEmailTemplate) - api.POST(p+"/config/emails/:id", app.SetCustomEmail) - api.POST(p+"/config/emails/:id/state/:state", app.SetCustomEmailState) + api.GET(p+"/config/emails", app.GetCustomContent) + api.GET(p+"/config/emails/:id", app.GetCustomMessageTemplate) + api.POST(p+"/config/emails/:id", app.SetCustomMessage) + api.POST(p+"/config/emails/:id/state/:state", app.SetCustomMessageState) api.GET(p+"/config", app.GetConfig) api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/restart", app.restart) diff --git a/storage.go b/storage.go index d24b960..095075d 100644 --- a/storage.go +++ b/storage.go @@ -21,23 +21,24 @@ type matrixStore map[string]MatrixUser type emailStore map[string]EmailAddress type Storage struct { - timePattern string - invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path, matrix_sql_path string - users map[string]time.Time // Map of Jellyfin User IDs to their expiry times. - invites Invites - profiles map[string]Profile - defaultProfile string - displayprefs, ombi_template map[string]interface{} - emails emailStore - telegram telegramStore // Map of Jellyfin User IDs to telegram users. - discord discordStore // Map of Jellyfin user IDs to discord users. - matrix matrixStore // Map of Jellyfin user IDs to Matrix users. - customEmails customEmails - policy mediabrowser.Policy - configuration mediabrowser.Configuration - lang Lang - announcements map[string]announcementTemplate - invitesLock, usersLock, discordLock, telegramLock, matrixLock, emailsLock sync.Mutex + timePattern string + invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path, matrix_sql_path, userPage_path string + users map[string]time.Time // Map of Jellyfin User IDs to their expiry times. + invites Invites + profiles map[string]Profile + defaultProfile string + displayprefs, ombi_template map[string]interface{} + emails emailStore + telegram telegramStore // Map of Jellyfin User IDs to telegram users. + discord discordStore // Map of Jellyfin user IDs to discord users. + matrix matrixStore // Map of Jellyfin user IDs to Matrix users. + customEmails customEmails + userPage userPageContent + policy mediabrowser.Policy + configuration mediabrowser.Configuration + lang Lang + announcements map[string]announcementTemplate + invitesLock, usersLock, discordLock, telegramLock, matrixLock, emailsLock sync.Mutex } // GetEmails returns a copy of the store. @@ -172,25 +173,30 @@ type EmailAddress struct { } type customEmails struct { - UserCreated customEmail `json:"userCreated"` - InviteExpiry customEmail `json:"inviteExpiry"` - PasswordReset customEmail `json:"passwordReset"` - UserDeleted customEmail `json:"userDeleted"` - UserDisabled customEmail `json:"userDisabled"` - UserEnabled customEmail `json:"userEnabled"` - InviteEmail customEmail `json:"inviteEmail"` - WelcomeEmail customEmail `json:"welcomeEmail"` - EmailConfirmation customEmail `json:"emailConfirmation"` - UserExpired customEmail `json:"userExpired"` + UserCreated customContent `json:"userCreated"` + InviteExpiry customContent `json:"inviteExpiry"` + PasswordReset customContent `json:"passwordReset"` + UserDeleted customContent `json:"userDeleted"` + UserDisabled customContent `json:"userDisabled"` + UserEnabled customContent `json:"userEnabled"` + InviteEmail customContent `json:"inviteEmail"` + WelcomeEmail customContent `json:"welcomeEmail"` + EmailConfirmation customContent `json:"emailConfirmation"` + UserExpired customContent `json:"userExpired"` } -type customEmail struct { +type customContent struct { Enabled bool `json:"enabled,omitempty"` Content string `json:"content"` Variables []string `json:"variables,omitempty"` Conditionals []string `json:"conditionals,omitempty"` } +type userPageContent struct { + Login customContent `json:"login"` + Page customContent `json:"page"` +} + // timePattern: %Y-%m-%dT%H:%M:%S.%f type Profile struct { @@ -981,6 +987,14 @@ func (st *Storage) storeCustomEmails() error { return storeJSON(st.customEmails_path, st.customEmails) } +func (st *Storage) loadUserPageContent() error { + return loadJSON(st.userPage_path, &st.userPage) +} + +func (st *Storage) storeUserPageContent() error { + return storeJSON(st.userPage_path, st.userPage) +} + func (st *Storage) loadPolicy() error { return loadJSON(st.policy_path, &st.policy) } diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 6c5282e..7d9a74d 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -542,7 +542,7 @@ export class settingsList { private _sections: { [name: string]: sectionPanel } private _buttons: { [name: string]: HTMLSpanElement } private _needsRestart: boolean = false; - private _emailEditor = new EmailEditor(); + private _messageEditor = new MessageEditor(); addSection = (name: string, s: Section, subButton?: HTMLElement) => { const section = new sectionPanel(s, name); @@ -713,7 +713,7 @@ export class settingsList { if (name in this._sections) { this._sections[name].update(settings.sections[name]); } else { - if (name == "messages") { + if (name == "messages" || name == "user_page") { const editButton = document.createElement("div"); editButton.classList.add("tooltip", "left"); editButton.innerHTML = ` @@ -724,7 +724,9 @@ export class settingsList { ${window.lang.get("strings", "customizeMessages")} `; - (editButton.querySelector("span.button") as HTMLSpanElement).onclick = this._emailEditor.showList; + (editButton.querySelector("span.button") as HTMLSpanElement).onclick = () => { + this._messageEditor.showList(name == "messages" ? "email" : "user"); + }; this.addSection(name, settings.sections[name], editButton); } else if (name == "updates") { const icon = document.createElement("span") as HTMLSpanElement; @@ -773,7 +775,7 @@ interface emailListEl { enabled: boolean; } -class EmailEditor { +class MessageEditor { private _currentID: string; private _names: { [id: string]: emailListEl }; private _content: string; @@ -884,8 +886,8 @@ class EmailEditor { // }, true); } - showList = () => { - _get("/config/emails?lang=" + window.language, null, (req: XMLHttpRequest) => { + showList = (filter?: string) => { + _get("/config/emails?lang=" + window.language + (filter ? "&filter=" + filter : ""), null, (req: XMLHttpRequest) => { if (req.readyState == 4) { if (req.status != 200) { window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs")); diff --git a/views.go b/views.go index 055ca88..6c145e2 100644 --- a/views.go +++ b/views.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" + "github.com/gomarkdown/markdown" "github.com/hrfee/mediabrowser" "github.com/steambap/captcha" ) @@ -203,6 +204,24 @@ func (app *appContext) MyUserPage(gc *gin.Context) { data["discordServerName"] = app.discord.serverName data["discordInviteLink"] = app.discord.inviteChannelName != "" } + + pageMessages := map[string]*customContent{ + "Login": app.getCustomMessage("UserLogin"), + "Page": app.getCustomMessage("UserPage"), + } + + for name, msg := range pageMessages { + if msg == nil { + continue + } + data[name+"MessageEnabled"] = msg.Enabled + if !msg.Enabled { + continue + } + // We don't template here, since the username is only known after login. + data[name+"MessageContent"] = template.HTML(markdown.ToHTML([]byte(msg.Content), nil, markdownRenderer)) + } + gcHTML(gc, http.StatusOK, "user.html", data) } From 99679a800d41b7f886632487b9db5b4726e38f69 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 21:54:55 +0100 Subject: [PATCH 28/54] userpage: add customizable message on page --- html/user.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/html/user.html b/html/user.html index b14c940..d04af15 100644 --- a/html/user.html +++ b/html/user.html @@ -73,7 +73,7 @@
-
+
{{ .strings.expiry }} @@ -83,6 +83,13 @@ {{ .strings.contactMethods }}
+ {{ if index . "PageMessageEnabled" }} + {{ if .PageMessageEnabled }} +
+ {{ .PageMessageContent }} +
+ {{ end }} + {{ end }}
From b546aeb44080ca16b4fe74f8ad95be8a54ff444e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Jun 2023 22:18:38 +0100 Subject: [PATCH 29/54] userpage: don't wrap contact methods, ellipsise --- ts/user.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ts/user.ts b/ts/user.ts index 39d9a8c..071fe84 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -94,17 +94,17 @@ class ContactMethods { append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => { const row = document.createElement("div"); - row.classList.add("row", "flex-expand", "my-2"); + row.classList.add("flex", "flex-expand", "my-2", "flex-nowrap"); let innerHTML = ` -
+
${icon} - ${(details.value == "") ? window.lang.strings("notSet") : details.value} + ${(details.value == "") ? window.lang.strings("notSet") : details.value}
-
+
{{ end }} {{ end }} +
+ {{ .strings.contactMethods }} +
+
{{ .strings.expiry }}
-
- {{ .strings.contactMethods }} -
-
From 4e7f72021443174cf1803f01d15a55fc3c6c834c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 17:02:57 +0100 Subject: [PATCH 35/54] userpage: hide bg on login, dont refresh page ever --- html/user.html | 2 +- ts/user.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/html/user.html b/html/user.html index 0c28641..c24eb83 100644 --- a/html/user.html +++ b/html/user.html @@ -69,7 +69,7 @@ {{ .strings.logout }}
-
+
diff --git a/ts/user.ts b/ts/user.ts index 609d1f9..84e0867 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -175,7 +175,7 @@ class ContactMethods { const deleteButton = row.querySelector(".user-contact-delete") as HTMLButtonElement; deleteButton.onclick = () => _delete("/my/" + name, null, (req: XMLHttpRequest) => { if (req.readyState != 4) return; - window.location.reload(); + document.dispatchEvent(new CustomEvent("details-reload")); }); } @@ -289,7 +289,7 @@ const addEditEmail = (add: boolean): void => { toggleLoader(submit); _post("/my/email", {"email": input.value}, (req: XMLHttpRequest) => { if (req.readyState == 4 && (req.status == 303 || req.status == 200)) { - window.location.reload(); + document.dispatchEvent(new CustomEvent("details-reload")); } }, true, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 401) { @@ -312,7 +312,7 @@ const discordConf: ServiceConfiguration = { accountLinkedError: window.lang.notif("errorAccountLinked"), successError: window.lang.notif("verified"), successFunc: (modalClosed: boolean) => { - if (modalClosed) window.location.reload(); + if (modalClosed) document.dispatchEvent(new CustomEvent("details-reload")); } }; @@ -327,7 +327,7 @@ const telegramConf: ServiceConfiguration = { accountLinkedError: window.lang.notif("errorAccountLinked"), successError: window.lang.notif("verified"), successFunc: (modalClosed: boolean) => { - if (modalClosed) window.location.reload(); + if (modalClosed) document.dispatchEvent(new CustomEvent("details-reload")); } }; @@ -342,7 +342,7 @@ const matrixConf: MatrixConfiguration = { unknownError: window.lang.notif("errorUnknown"), successError: window.lang.notif("verified"), successFunc: () => { - setTimeout(() => window.location.reload(), 1200); + setTimeout(() => document.dispatchEvent(new CustomEvent("details-reload")), 1200); } }; @@ -431,6 +431,7 @@ document.addEventListener("details-reload", () => { const login = new Login(window.modals.login as Modal, "/my/"); login.onLogin = () => { console.log("Logged in."); + document.querySelector(".page-container").classList.remove("unfocused"); document.dispatchEvent(new CustomEvent("details-reload")); }; From 761d8d1c03094bd0fc206f9aaabe9f8a804caa32 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 17:07:02 +0100 Subject: [PATCH 36/54] userpage: refresh pin when contact changed > once --- ts/modules/account-linking.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index ea3994f..45b1311 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -59,6 +59,7 @@ export class ServiceLinker { protected _waiting: HTMLSpanElement; protected _verified = false; protected _name: string; + protected _pin: string; get verified(): boolean { return this._verified; } @@ -76,7 +77,7 @@ export class ServiceLinker { setTimeout(this._checkVerified, 1500); return; } - _get(this._conf.verifiedURL + this._conf.pin, null, (req: XMLHttpRequest) => { + _get(this._conf.verifiedURL + this._pin, null, (req: XMLHttpRequest) => { if (req.readyState != 4) return; if (req.status == 401) { this._conf.modal.close(); @@ -111,14 +112,16 @@ export class ServiceLinker { toggleLoader(this._waiting); this._pinAcquired = false; + this._pin = ""; if (this._conf.pin) { this._pinAcquired = true; - this._conf.modal.modal.querySelector(".pin").textContent = this._conf.pin; + this._pin = this._conf.pin; + this._conf.modal.modal.querySelector(".pin").textContent = this._pin; } else if (this._conf.pinURL) { _get(this._conf.pinURL, null, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 200) { - this._conf.pin = req.response["pin"]; - this._conf.modal.modal.querySelector(".pin").textContent = this._conf.pin; + this._pin = req.response["pin"]; + this._conf.modal.modal.querySelector(".pin").textContent = this._pin; this._pinAcquired = true; } }); From 3747eaa3a7c3827cac61f7290a25ccc336201b61 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 17:59:58 +0100 Subject: [PATCH 37/54] messages: refactor dc/tg, fix tg less external access to Discord/TelegramDaemon internals, will be easier to keep user/admin-side uses functioning similarly. Also changed their internal token stores to use a map, and store an expiry. verifiedTokens is also now a map in telegram. Also fixed issue where token wasn't being deleted after use on the user page. --- api-messages.go | 77 ++++++++++--------------------------------------- api-userpage.go | 42 ++++++++++----------------- api-users.go | 48 +++++++++++------------------- discord.go | 59 ++++++++++++++++++++++--------------- storage.go | 18 ++++++++++++ telegram.go | 60 +++++++++++++++++++++++++------------- 6 files changed, 143 insertions(+), 161 deletions(-) diff --git a/api-messages.go b/api-messages.go index dcaecc4..d4ec8ff 100644 --- a/api-messages.go +++ b/api-messages.go @@ -332,18 +332,12 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) { respondBool(400, false, gc) return } - tokenIndex := -1 - for i, v := range app.telegram.verifiedTokens { - if v.Token == req.Token { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + tgToken, ok := app.telegram.TokenVerified(req.Token) + app.telegram.DeleteVerifiedToken(req.Token) + if !ok { respondBool(500, false, gc) return } - tgToken := app.telegram.verifiedTokens[tokenIndex] tgUser := TelegramUser{ ChatID: tgToken.ChatID, Username: tgToken.Username, @@ -352,17 +346,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) { if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { tgUser.Lang = lang } - if app.storage.GetTelegram() == nil { - app.storage.telegram = telegramStore{} - } app.storage.SetTelegramKey(req.ID, tgUser) - err := app.storage.storeTelegramUsers() - if err != nil { - app.err.Printf("Failed to store Telegram users: %v", err) - } else { - app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] - app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] - } linkExistingOmbiDiscordTelegram(app) respondBool(200, true, gc) } @@ -462,19 +446,8 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte // @tags Other func (app *appContext) TelegramVerified(gc *gin.Context) { pin := gc.Param("pin") - tokenIndex := -1 - for i, v := range app.telegram.verifiedTokens { - if v.Token == pin { - tokenIndex = i - break - } - } - // if tokenIndex != -1 { - // length := len(app.telegram.verifiedTokens) - // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] - // app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1] - // } - respondBool(200, tokenIndex != -1, gc) + _, ok := app.telegram.TokenVerified(pin) + respondBool(200, ok, gc) } // @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. @@ -492,27 +465,13 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { return } pin := gc.Param("pin") - tokenIndex := -1 - for i, v := range app.telegram.verifiedTokens { - if v.Token == pin { - tokenIndex = i - break - } + token, ok := app.telegram.TokenVerified(pin) + if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) { + app.discord.DeleteVerifiedUser(pin) + respondBool(400, false, gc) + return } - if app.config.Section("telegram").Key("require_unique").MustBool(false) { - for _, u := range app.storage.GetTelegram() { - if app.telegram.verifiedTokens[tokenIndex].Username == u.Username { - respondBool(400, false, gc) - return - } - } - } - // if tokenIndex != -1 { - // length := len(app.telegram.verifiedTokens) - // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] - // app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1] - // } - respondBool(200, tokenIndex != -1, gc) + respondBool(200, ok, gc) } // @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. @@ -530,15 +489,11 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { return } pin := gc.Param("pin") - _, ok := app.discord.verifiedTokens[pin] - if app.config.Section("discord").Key("require_unique").MustBool(false) { - for _, u := range app.storage.GetDiscord() { - if app.discord.verifiedTokens[pin].ID == u.ID { - delete(app.discord.verifiedTokens, pin) - respondBool(400, false, gc) - return - } - } + user, ok := app.discord.UserVerified(pin) + if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) { + delete(app.discord.verifiedTokens, pin) + respondBool(400, false, gc) + return } respondBool(200, ok, gc) } diff --git a/api-userpage.go b/api-userpage.go index dd908d5..bada208 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -322,19 +322,15 @@ func (app *appContext) GetMyPIN(gc *gin.Context) { // @tags User Page func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") - dcUser, ok := app.discord.verifiedTokens[pin] + dcUser, ok := app.discord.UserVerified(pin) + app.discord.DeleteVerifiedUser(pin) if !ok { respondBool(200, false, gc) return } - if app.config.Section("discord").Key("require_unique").MustBool(false) { - for _, u := range app.storage.GetDiscord() { - if app.discord.verifiedTokens[pin].ID == u.ID { - delete(app.discord.verifiedTokens, pin) - respondBool(400, false, gc) - return - } - } + if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(dcUser.ID) { + respondBool(400, false, gc) + return } existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId")) if ok { @@ -354,30 +350,24 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { // @tags User Page func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") - tokenIndex := -1 - for i, v := range app.telegram.verifiedTokens { - if v.Token == pin { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + token, ok := app.telegram.TokenVerified(pin) + app.telegram.DeleteVerifiedToken(pin) + if !ok { respondBool(200, false, gc) return } - if app.config.Section("telegram").Key("require_unique").MustBool(false) { - for _, u := range app.storage.GetTelegram() { - if app.telegram.verifiedTokens[tokenIndex].Username == u.Username { - respondBool(400, false, gc) - return - } - } + if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) { + respondBool(400, false, gc) + return } tgUser := TelegramUser{ - ChatID: app.telegram.verifiedTokens[tokenIndex].ChatID, - Username: app.telegram.verifiedTokens[tokenIndex].Username, + ChatID: token.ChatID, + Username: token.Username, Contact: true, } + if lang, ok := app.telegram.languages[tgUser.ChatID]; ok { + tgUser.Lang = lang + } existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId")) if ok { diff --git a/api-users.go b/api-users.go index 343139d..94d5b59 100644 --- a/api-users.go +++ b/api-users.go @@ -193,7 +193,8 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } - telegramTokenIndex := -1 + var tgToken TelegramVerifiedToken + telegramVerified := false if telegramEnabled { if req.TelegramPIN == "" { if app.config.Section("telegram").Key("required").MustBool(false) { @@ -205,13 +206,8 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } } else { - for i, v := range app.telegram.verifiedTokens { - if v.Token == req.TelegramPIN { - telegramTokenIndex = i - break - } - } - if telegramTokenIndex == -1 { + tgToken, telegramVerified = app.telegram.TokenVerified(req.TelegramPIN) + if telegramVerified { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code) respond(401, "errorInvalidPIN", gc) @@ -219,17 +215,13 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } - if app.config.Section("telegram").Key("require_unique").MustBool(false) { - for _, u := range app.storage.GetTelegram() { - if app.telegram.verifiedTokens[telegramTokenIndex].Username == u.Username { - f = func(gc *gin.Context) { - app.debug.Printf("%s: New user failed: Telegram user already linked", req.Code) - respond(400, "errorAccountLinked", gc) - } - success = false - return - } + if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(tgToken.Username) { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Telegram user already linked", req.Code) + respond(400, "errorAccountLinked", gc) } + success = false + return } } } @@ -352,7 +344,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.err.Printf("Failed to store user duration: %v", err) } } - if discordEnabled && discordVerified { + if discordVerified { discordUser.Contact = req.DiscordContact if app.storage.discord == nil { app.storage.discord = discordStore{} @@ -364,8 +356,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc delete(app.discord.verifiedTokens, req.DiscordPIN) } } - if telegramEnabled && telegramTokenIndex != -1 { - tgToken := app.telegram.verifiedTokens[telegramTokenIndex] + if telegramVerified { tgUser := TelegramUser{ ChatID: tgToken.ChatID, Username: tgToken.Username, @@ -377,13 +368,8 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc if app.storage.telegram == nil { app.storage.telegram = telegramStore{} } + app.telegram.DeleteVerifiedToken(req.TelegramPIN) app.storage.SetTelegramKey(user.ID, tgUser) - if err := app.storage.storeTelegramUsers(); err != nil { - app.err.Printf("Failed to store Telegram users: %v", err) - } else { - app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[telegramTokenIndex] = app.telegram.verifiedTokens[telegramTokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] - app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] - } } if invite.Profile != "" && app.config.Section("ombi").Key("enabled").MustBool(false) { if profile.Ombi != nil && len(profile.Ombi) != 0 { @@ -394,17 +380,17 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", ")) } else { app.info.Println("Created Ombi user") - if (discordEnabled && discordVerified) || (telegramEnabled && telegramTokenIndex != -1) { + if discordVerified || telegramVerified { ombiUser, status, err := app.getOmbiUser(id) if status != 200 || err != nil { app.err.Printf("Failed to get Ombi user (%d): %v", status, err) } else { dID := "" tUser := "" - if discordEnabled && discordVerified { + if discordVerified { dID = discordUser.ID } - if telegramEnabled && telegramTokenIndex != -1 { + if telegramVerified { u, _ := app.storage.GetTelegramKey(user.ID) tUser = u.Username } @@ -431,7 +417,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.err.Printf("Failed to store Matrix users: %v", err) } } - if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 || discordVerified { + if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramVerified || discordVerified || matrixVerified { name := app.getAddressOrName(user.ID) app.debug.Printf("%s: Sending welcome message to %s", req.Username, name) msg, err := app.email.constructWelcome(req.Username, expiry, app, false) diff --git a/discord.go b/discord.go index 4fc98be..1c2c58e 100644 --- a/discord.go +++ b/discord.go @@ -3,6 +3,7 @@ package main import ( "fmt" "strings" + "time" dg "github.com/bwmarrin/discordgo" ) @@ -12,7 +13,7 @@ type DiscordDaemon struct { ShutdownChannel chan string bot *dg.Session username string - tokens []string + tokens map[string]time.Time // Map of tokens to expiry times. verifiedTokens map[string]DiscordUser // Map of tokens to discord users. channelID, channelName, inviteChannelID, inviteChannelName string guildID string @@ -37,7 +38,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { Stopped: false, ShutdownChannel: make(chan string), bot: bot, - tokens: []string{}, + tokens: map[string]time.Time{}, verifiedTokens: map[string]DiscordUser{}, users: map[string]DiscordUser{}, app: app, @@ -58,7 +59,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { // NewAuthToken generates an 8-character pin in the form "A1-2B-CD". func (d *DiscordDaemon) NewAuthToken() string { pin := genAuthToken() - d.tokens = append(d.tokens, pin) + d.tokens[pin] = time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second) return pin } @@ -431,14 +432,8 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) { pin := i.ApplicationCommandData().Options[0].StringValue() - tokenIndex := -1 - for i, token := range d.tokens { - if pin == token { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + expiry, ok := d.tokens[pin] + if !ok || time.Now().After(expiry) { err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ // Type: dg.InteractionResponseChannelMessageWithSource, Type: dg.InteractionResponseChannelMessageWithSource, @@ -450,6 +445,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri if err != nil { d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) } + delete(d.tokens, pin) return } err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ @@ -464,8 +460,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) } d.verifiedTokens[pin] = d.users[i.Interaction.Member.User.ID] - d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1] - d.tokens = d.tokens[:len(d.tokens)-1] + delete(d.tokens, pin) } func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang string) { @@ -606,14 +601,8 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin d.app.debug.Println("Discord: Ignoring message as user was not found") return } - tokenIndex := -1 - for i, token := range d.tokens { - if sects[0] == token { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + expiry, ok := d.tokens[sects[0]] + if !ok || time.Now().After(expiry) { _, err := s.ChannelMessageSend( m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"), @@ -621,6 +610,7 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin if err != nil { d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) } + delete(d.tokens, sects[0]) return } _, err := s.ChannelMessageSend( @@ -631,8 +621,7 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) } d.verifiedTokens[sects[0]] = d.users[m.Author.ID] - d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1] - d.tokens = d.tokens[:len(d.tokens)-1] + delete(d.tokens, sects[0]) } func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error { @@ -686,3 +675,27 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error { } return nil } + +// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself. +func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) { + user, ok = d.verifiedTokens[pin] + // delete(d.verifiedTokens, pin) + return +} + +// UserExists returns whether or not a user with the given ID exists. +func (d *DiscordDaemon) UserExists(id string) (ok bool) { + ok = false + for _, u := range d.app.storage.GetDiscord() { + if u.ID == id { + ok = true + break + } + } + return +} + +// DeleteVerifiedUser removes the token with the given PIN. +func (d *DiscordDaemon) DeleteVerifiedUser(pin string) { + delete(d.verifiedTokens, pin) +} diff --git a/storage.go b/storage.go index 095075d..de49b5d 100644 --- a/storage.go +++ b/storage.go @@ -70,6 +70,9 @@ func (st *Storage) DeleteEmailsKey(k string) { // GetDiscord returns a copy of the store. func (st *Storage) GetDiscord() discordStore { + if st.discord == nil { + st.discord = discordStore{} + } return st.discord } @@ -82,6 +85,9 @@ func (st *Storage) GetDiscordKey(k string) (DiscordUser, bool) { // SetDiscordKey stores value v in key k. func (st *Storage) SetDiscordKey(k string, v DiscordUser) { st.discordLock.Lock() + if st.discord == nil { + st.discord = discordStore{} + } st.discord[k] = v st.storeDiscordUsers() st.discordLock.Unlock() @@ -97,6 +103,9 @@ func (st *Storage) DeleteDiscordKey(k string) { // GetTelegram returns a copy of the store. func (st *Storage) GetTelegram() telegramStore { + if st.telegram == nil { + st.telegram = telegramStore{} + } return st.telegram } @@ -109,6 +118,9 @@ func (st *Storage) GetTelegramKey(k string) (TelegramUser, bool) { // SetTelegramKey stores value v in key k. func (st *Storage) SetTelegramKey(k string, v TelegramUser) { st.telegramLock.Lock() + if st.telegram == nil { + st.telegram = telegramStore{} + } st.telegram[k] = v st.storeTelegramUsers() st.telegramLock.Unlock() @@ -124,6 +136,9 @@ func (st *Storage) DeleteTelegramKey(k string) { // GetMatrix returns a copy of the store. func (st *Storage) GetMatrix() matrixStore { + if st.matrix == nil { + st.matrix = matrixStore{} + } return st.matrix } @@ -136,6 +151,9 @@ func (st *Storage) GetMatrixKey(k string) (MatrixUser, bool) { // SetMatrixKey stores value v in key k. func (st *Storage) SetMatrixKey(k string, v MatrixUser) { st.matrixLock.Lock() + if st.matrix == nil { + st.matrix = matrixStore{} + } st.matrix[k] = v st.storeMatrixUsers() st.matrixLock.Unlock() diff --git a/telegram.go b/telegram.go index 288d95d..52d9291 100644 --- a/telegram.go +++ b/telegram.go @@ -9,8 +9,11 @@ import ( tg "github.com/go-telegram-bot-api/telegram-bot-api" ) +const ( + VERIF_TOKEN_EXPIRY_SEC = 10 * 60 +) + type TelegramVerifiedToken struct { - Token string ChatID int64 Username string } @@ -20,9 +23,9 @@ type TelegramDaemon struct { ShutdownChannel chan string bot *tg.BotAPI username string - tokens []string - verifiedTokens []TelegramVerifiedToken - languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start. + tokens map[string]time.Time // Map of tokens to their expiry time. + verifiedTokens map[string]TelegramVerifiedToken // Map of tokens to the responsible ChatID+Username. + languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start. link string app *appContext } @@ -40,8 +43,8 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { ShutdownChannel: make(chan string), bot: bot, username: bot.Self.UserName, - tokens: []string{}, - verifiedTokens: []TelegramVerifiedToken{}, + tokens: map[string]time.Time{}, + verifiedTokens: map[string]TelegramVerifiedToken{}, languages: map[int64]string{}, link: "https://t.me/" + bot.Self.UserName, app: app, @@ -72,7 +75,7 @@ var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") // NewAuthToken generates an 8-character pin in the form "A1-2B-CD". func (t *TelegramDaemon) NewAuthToken() string { pin := genAuthToken() - t.tokens = append(t.tokens, pin) + t.tokens[pin] = time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second) return pin } @@ -212,29 +215,46 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string } func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) { - tokenIndex := -1 - for i, token := range t.tokens { - if upd.Message.Text == token { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + expiry, ok := t.tokens[upd.Message.Text] + if !ok || time.Now().After(expiry) { err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN")) if err != nil { t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) } + delete(t.tokens, upd.Message.Text) return } err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess")) if err != nil { t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) } - t.verifiedTokens = append(t.verifiedTokens, TelegramVerifiedToken{ - Token: upd.Message.Text, + t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{ ChatID: upd.Message.Chat.ID, Username: upd.Message.Chat.UserName, - }) - t.tokens[len(t.tokens)-1], t.tokens[tokenIndex] = t.tokens[tokenIndex], t.tokens[len(t.tokens)-1] - t.tokens = t.tokens[:len(t.tokens)-1] + } + delete(t.tokens, upd.Message.Text) +} + +// TokenVerified returns whether or not a token with the given PIN has been verified, and the token itself. +func (t *TelegramDaemon) TokenVerified(pin string) (token TelegramVerifiedToken, ok bool) { + token, ok = t.verifiedTokens[pin] + // delete(t.verifiedTokens, pin) + return +} + +// UserExists returns whether or not a user with the given username exists. +func (t *TelegramDaemon) UserExists(username string) (ok bool) { + ok = false + for _, u := range t.app.storage.GetTelegram() { + if u.Username == username { + ok = true + break + } + } + return +} + +// DeleteVerifiedToken removes the token with the given PIN. +func (t *TelegramDaemon) DeleteVerifiedToken(pin string) { + delete(t.verifiedTokens, pin) } From e4a71725176fa0d7e5c858d1c49cafd7665c7f9a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 18:26:08 +0100 Subject: [PATCH 38/54] messages: assign tokens to jf users on userpage pins generated on the user page are assigned to that user, no other jellyifn user can verify them. --- api-userpage.go | 8 ++++---- discord.go | 43 +++++++++++++++++++++++++++++++++---------- storage.go | 1 + telegram.go | 47 +++++++++++++++++++++++++++++++++++++---------- 4 files changed, 75 insertions(+), 24 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index bada208..93f856a 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -301,10 +301,10 @@ func (app *appContext) GetMyPIN(gc *gin.Context) { resp := GetMyPINDTO{} switch service { case "discord": - resp.PIN = app.discord.NewAuthToken() + resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId")) break case "telegram": - resp.PIN = app.telegram.NewAuthToken() + resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId")) break default: respond(400, "invalid service", gc) @@ -322,7 +322,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) { // @tags User Page func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") - dcUser, ok := app.discord.UserVerified(pin) + dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId")) app.discord.DeleteVerifiedUser(pin) if !ok { respondBool(200, false, gc) @@ -350,7 +350,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { // @tags User Page func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") - token, ok := app.telegram.TokenVerified(pin) + token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId")) app.telegram.DeleteVerifiedToken(pin) if !ok { respondBool(200, false, gc) diff --git a/discord.go b/discord.go index 1c2c58e..607696c 100644 --- a/discord.go +++ b/discord.go @@ -13,8 +13,8 @@ type DiscordDaemon struct { ShutdownChannel chan string bot *dg.Session username string - tokens map[string]time.Time // Map of tokens to expiry times. - verifiedTokens map[string]DiscordUser // Map of tokens to discord users. + tokens map[string]VerifToken // Map of pins to tokens. + verifiedTokens map[string]DiscordUser // Map of token pins to discord users. channelID, channelName, inviteChannelID, inviteChannelName string guildID string serverChannelName, serverName string @@ -38,7 +38,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { Stopped: false, ShutdownChannel: make(chan string), bot: bot, - tokens: map[string]time.Time{}, + tokens: map[string]VerifToken{}, verifiedTokens: map[string]DiscordUser{}, users: map[string]DiscordUser{}, app: app, @@ -59,7 +59,15 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { // NewAuthToken generates an 8-character pin in the form "A1-2B-CD". func (d *DiscordDaemon) NewAuthToken() string { pin := genAuthToken() - d.tokens[pin] = time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second) + d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""} + return pin +} + +// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD", +// and assigns it for access only with the given Jellyfin ID. +func (d *DiscordDaemon) NewAssignedAuthToken(id string) string { + pin := genAuthToken() + d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id} return pin } @@ -432,8 +440,8 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) { pin := i.ApplicationCommandData().Options[0].StringValue() - expiry, ok := d.tokens[pin] - if !ok || time.Now().After(expiry) { + user, ok := d.tokens[pin] + if !ok || time.Now().After(user.Expiry) { err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ // Type: dg.InteractionResponseChannelMessageWithSource, Type: dg.InteractionResponseChannelMessageWithSource, @@ -459,7 +467,9 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri if err != nil { d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) } - d.verifiedTokens[pin] = d.users[i.Interaction.Member.User.ID] + dcUser := d.users[i.Interaction.Member.User.ID] + dcUser.JellyfinID = user.JellyfinID + d.verifiedTokens[pin] = dcUser delete(d.tokens, pin) } @@ -601,8 +611,8 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin d.app.debug.Println("Discord: Ignoring message as user was not found") return } - expiry, ok := d.tokens[sects[0]] - if !ok || time.Now().After(expiry) { + user, ok := d.tokens[sects[0]] + if !ok || time.Now().After(user.Expiry) { _, err := s.ChannelMessageSend( m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"), @@ -620,7 +630,9 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin if err != nil { d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) } - d.verifiedTokens[sects[0]] = d.users[m.Author.ID] + dcUser := d.users[m.Author.ID] + dcUser.JellyfinID = user.JellyfinID + d.verifiedTokens[sects[0]] = dcUser delete(d.tokens, sects[0]) } @@ -683,6 +695,17 @@ func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) { return } +// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself. +// Returns false if the given Jellyfin ID does not match the one in the user. +func (d *DiscordDaemon) AssignedUserVerified(pin string, jfID string) (user DiscordUser, ok bool) { + user, ok = d.verifiedTokens[pin] + if ok && user.JellyfinID != jfID { + ok = false + } + // delete(d.verifiedUsers, pin) + return +} + // UserExists returns whether or not a user with the given ID exists. func (d *DiscordDaemon) UserExists(id string) (ok bool) { ok = false diff --git a/storage.go b/storage.go index de49b5d..5d66d0b 100644 --- a/storage.go +++ b/storage.go @@ -181,6 +181,7 @@ type DiscordUser struct { Discriminator string Lang string Contact bool + JellyfinID string `json:"-"` // Used internally in discord.go } type EmailAddress struct { diff --git a/telegram.go b/telegram.go index 52d9291..a50292c 100644 --- a/telegram.go +++ b/telegram.go @@ -14,8 +14,15 @@ const ( ) type TelegramVerifiedToken struct { - ChatID int64 - Username string + ChatID int64 + Username string + JellyfinID string // optional, for ensuring a user-requested change is only accessed by them. +} + +// VerifToken stores details about a pending user verification token. +type VerifToken struct { + Expiry time.Time + JellyfinID string // optional, for ensuring a user-requested change is only accessed by them. } type TelegramDaemon struct { @@ -23,8 +30,8 @@ type TelegramDaemon struct { ShutdownChannel chan string bot *tg.BotAPI username string - tokens map[string]time.Time // Map of tokens to their expiry time. - verifiedTokens map[string]TelegramVerifiedToken // Map of tokens to the responsible ChatID+Username. + tokens map[string]VerifToken // Map of pins to tokens. + verifiedTokens map[string]TelegramVerifiedToken // Map of token pins to the responsible ChatID+Username. languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start. link string app *appContext @@ -43,7 +50,7 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { ShutdownChannel: make(chan string), bot: bot, username: bot.Self.UserName, - tokens: map[string]time.Time{}, + tokens: map[string]VerifToken{}, verifiedTokens: map[string]TelegramVerifiedToken{}, languages: map[int64]string{}, link: "https://t.me/" + bot.Self.UserName, @@ -75,7 +82,15 @@ var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") // NewAuthToken generates an 8-character pin in the form "A1-2B-CD". func (t *TelegramDaemon) NewAuthToken() string { pin := genAuthToken() - t.tokens[pin] = time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second) + t.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""} + return pin +} + +// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD", +// and assigns it for access only with the given Jellyfin ID. +func (t *TelegramDaemon) NewAssignedAuthToken(id string) string { + pin := genAuthToken() + t.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id} return pin } @@ -215,8 +230,8 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string } func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) { - expiry, ok := t.tokens[upd.Message.Text] - if !ok || time.Now().After(expiry) { + token, ok := t.tokens[upd.Message.Text] + if !ok || time.Now().After(token.Expiry) { err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN")) if err != nil { t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) @@ -229,8 +244,9 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) } t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{ - ChatID: upd.Message.Chat.ID, - Username: upd.Message.Chat.UserName, + ChatID: upd.Message.Chat.ID, + Username: upd.Message.Chat.UserName, + JellyfinID: token.JellyfinID, } delete(t.tokens, upd.Message.Text) } @@ -242,6 +258,17 @@ func (t *TelegramDaemon) TokenVerified(pin string) (token TelegramVerifiedToken, return } +// AssignedTokenVerified returns whether or not a token with the given PIN has been verified, and the token itself. +// Returns false if the given Jellyfin ID does not match the one in the token. +func (t *TelegramDaemon) AssignedTokenVerified(pin string, jfID string) (token TelegramVerifiedToken, ok bool) { + token, ok = t.verifiedTokens[pin] + if ok && token.JellyfinID != jfID { + ok = false + } + // delete(t.verifiedTokens, pin) + return +} + // UserExists returns whether or not a user with the given username exists. func (t *TelegramDaemon) UserExists(username string) (ok bool) { ok = false From ebacfd43be5e292248a5e175afc56aac7e08c17c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 20:00:48 +0100 Subject: [PATCH 39/54] form: fix captcha, matrix, telegram new issue though: discord/telegram/matrix aren't linked when email confirmation is used! --- api-users.go | 4 +-- html/create-success.html | 2 +- router.go | 2 +- ts/form.ts | 63 +++++++++++++++-------------------- ts/modules/account-linking.ts | 4 +-- 5 files changed, 33 insertions(+), 42 deletions(-) diff --git a/api-users.go b/api-users.go index 94d5b59..4a54e01 100644 --- a/api-users.go +++ b/api-users.go @@ -121,7 +121,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } } else { - discordUser, discordVerified = app.discord.verifiedTokens[req.DiscordPIN] + discordUser, discordVerified = app.discord.UserVerified(req.DiscordPIN) if !discordVerified { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Discord PIN was invalid", req.Code) @@ -207,7 +207,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } else { tgToken, telegramVerified = app.telegram.TokenVerified(req.TelegramPIN) - if telegramVerified { + if !telegramVerified { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code) respond(401, "errorInvalidPIN", gc) diff --git a/html/create-success.html b/html/create-success.html index 4988e8e..c4e87d7 100644 --- a/html/create-success.html +++ b/html/create-success.html @@ -1,7 +1,7 @@ - + {{ template "header.html" . }} {{ .strings.successHeader }} - jfa-go diff --git a/router.go b/router.go index 72faa01..f0fd890 100644 --- a/router.go +++ b/router.go @@ -236,7 +236,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.GET(p+"/discord/verified/:pin", app.MyDiscordVerifiedInvite) user.GET(p+"/telegram/verified/:pin", app.MyTelegramVerifiedInvite) user.POST(p+"/matrix/user", app.MatrixSendMyPIN) - user.POST(p+"/matrix/verified/:userID/:pin", app.MatrixCheckMyPIN) + user.GET(p+"/matrix/verified/:userID/:pin", app.MatrixCheckMyPIN) user.DELETE(p+"/discord", app.UnlinkMyDiscord) user.DELETE(p+"/telegram", app.UnlinkMyTelegram) user.DELETE(p+"/matrix", app.UnlinkMyMatrix) diff --git a/ts/form.ts b/ts/form.ts index 961de8a..428f19f 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -176,33 +176,31 @@ let captchaInput = document.getElementById("captcha-input") as HTMLInputElement; const captchaCheckbox = document.getElementById("captcha-success") as HTMLSpanElement; let prevCaptcha = ""; -function baseValidator(oncomplete: (valid: boolean) => void): void { - let captchaChecked = false; - let captchaChange = false; - if (window.captcha && !window.reCAPTCHA) { - captchaChange = captchaInput.value != prevCaptcha; - if (captchaChange) { - prevCaptcha = captchaInput.value; - _post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - if (req.status == 204) { - captchaCheckbox.innerHTML = ``; - captchaCheckbox.classList.add("~positive"); - captchaCheckbox.classList.remove("~critical"); - captchaVerified = true; - captchaChecked = true; - } else { - captchaCheckbox.innerHTML = ``; - captchaCheckbox.classList.add("~critical"); - captchaCheckbox.classList.remove("~positive"); - captchaVerified = false; - captchaChecked = true; - return; - } +let baseValidator = (oncomplete: (valid: boolean) => void): void => { + if (window.captcha && !window.reCAPTCHA && (captchaInput.value != prevCaptcha)) { + prevCaptcha = captchaInput.value; + _post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status == 204) { + captchaCheckbox.innerHTML = ``; + captchaCheckbox.classList.add("~positive"); + captchaCheckbox.classList.remove("~critical"); + captchaVerified = true; + } else { + captchaCheckbox.innerHTML = ``; + captchaCheckbox.classList.add("~critical"); + captchaCheckbox.classList.remove("~positive"); + captchaVerified = false; } - }); - } + _baseValidator(oncomplete, captchaVerified); + } + }); + } else { + _baseValidator(oncomplete, captchaVerified); } +} + +function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void { if (window.emailRequired) { if (!emailField.value.includes("@")) { oncomplete(false); @@ -221,18 +219,11 @@ function baseValidator(oncomplete: (valid: boolean) => void): void { oncomplete(false); return; } - if (window.captcha && !window.reCAPTCHA) { - if (!captchaChange) { - oncomplete(captchaVerified); - return; - } - while (!captchaChecked) { - continue; - } - oncomplete(captchaVerified); - } else { - oncomplete(true); + if (window.captcha && !window.reCAPTCHA && !captchaValid) { + oncomplete(false); + return; } + oncomplete(true); } interface GreCAPTCHA { diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index 45b1311..bdc92be 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -243,7 +243,7 @@ export class Matrix { this._input.value = ""; }); - private _verifyCode = () => _post(this._conf.verifiedURL + this._userID + "/" + this._input.value, null, (req: XMLHttpRequest) => { + private _verifyCode = () => _get(this._conf.verifiedURL + this._userID + "/" + this._input.value, null, (req: XMLHttpRequest) => { if (req.readyState != 4) return; removeLoader(this._submit); const valid = req.response["success"] as boolean; @@ -264,6 +264,6 @@ export class Matrix { this._submit.classList.remove("~critical"); }, 800); } - }, true); + }); } From f779f0345e016973986e1388d1805e7780ef9e0b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 20:39:16 +0100 Subject: [PATCH 40/54] storage: Use familiar api for invite access An almost identical set of functions to the discord/telegram/matrix storage ones is now used for accessing invites. No more parallelism-related issues, yay. Need to do this for everything eventually. --- api-invites.go | 28 +++++++++++++-------------- api-messages.go | 10 +++++----- api-users.go | 24 +++++++++++------------ storage.go | 49 +++++++++++++++++++++++++++++++++++++---------- views.go | 51 ++++++++++++++++--------------------------------- 5 files changed, 85 insertions(+), 77 deletions(-) diff --git a/api-invites.go b/api-invites.go index a41bd57..268fdac 100644 --- a/api-invites.go +++ b/api-invites.go @@ -16,7 +16,7 @@ func (app *appContext) checkInvites() { currentTime := time.Now() app.storage.loadInvites() changed := false - for code, data := range app.storage.invites { + for code, data := range app.storage.GetInvites() { expiry := data.ValidTill if !currentTime.After(expiry) { continue @@ -54,7 +54,7 @@ func (app *appContext) checkInvites() { wait.Wait() } changed = true - delete(app.storage.invites, code) + app.storage.DeleteInvitesKey(code) } if changed { app.storage.storeInvites() @@ -65,7 +65,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool currentTime := time.Now() app.storage.loadInvites() changed := false - inv, match := app.storage.invites[code] + inv, match := app.storage.GetInvitesKey(code) if !match { return false } @@ -105,21 +105,21 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool } changed = true match = false - delete(app.storage.invites, code) + app.storage.DeleteInvitesKey(code) } else if used { changed = true del := false newInv := inv if newInv.RemainingUses == 1 { del = true - delete(app.storage.invites, code) + app.storage.DeleteInvitesKey(code) } else if newInv.RemainingUses != 0 { // 0 means infinite i guess? newInv.RemainingUses-- } newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)}) if !del { - app.storage.invites[code] = newInv + app.storage.SetInvitesKey(code, newInv) } } if changed { @@ -219,7 +219,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) { invite.Profile = "Default" } } - app.storage.invites[inviteCode] = invite + app.storage.SetInvitesKey(inviteCode, invite) app.storage.storeInvites() respondBool(200, true, gc) } @@ -236,7 +236,7 @@ func (app *appContext) GetInvites(gc *gin.Context) { app.storage.loadInvites() app.checkInvites() var invites []inviteDTO - for code, inv := range app.storage.invites { + for code, inv := range app.storage.GetInvites() { _, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) invite := inviteDTO{ Code: code, @@ -335,9 +335,9 @@ func (app *appContext) SetProfile(gc *gin.Context) { respond(500, "Profile not found", gc) return } - inv := app.storage.invites[req.Invite] + inv, _ := app.storage.GetInvitesKey(req.Invite) inv.Profile = req.Profile - app.storage.invites[req.Invite] = inv + app.storage.SetInvitesKey(req.Invite, inv) app.storage.storeInvites() respondBool(200, true, gc) } @@ -359,7 +359,7 @@ func (app *appContext) SetNotify(gc *gin.Context) { app.debug.Printf("%s: Notification settings change requested", code) app.storage.loadInvites() app.storage.loadEmails() - invite, ok := app.storage.invites[code] + invite, ok := app.storage.GetInvitesKey(code) if !ok { app.err.Printf("%s Notification setting change failed: Invalid code", code) respond(400, "Invalid invite code", gc) @@ -398,7 +398,7 @@ func (app *appContext) SetNotify(gc *gin.Context) { changed = true } if changed { - app.storage.invites[code] = invite + app.storage.SetInvitesKey(code, invite) } } if changed { @@ -419,9 +419,9 @@ func (app *appContext) DeleteInvite(gc *gin.Context) { gc.BindJSON(&req) app.debug.Printf("%s: Deletion requested", req.Code) var ok bool - _, ok = app.storage.invites[req.Code] + _, ok = app.storage.GetInvitesKey(req.Code) if ok { - delete(app.storage.invites, req.Code) + app.storage.DeleteInvitesKey(req.Code) app.storage.storeInvites() app.info.Printf("%s: Invite deleted", req.Code) respondBool(200, true, gc) diff --git a/api-messages.go b/api-messages.go index d4ec8ff..c571eb7 100644 --- a/api-messages.go +++ b/api-messages.go @@ -460,7 +460,7 @@ func (app *appContext) TelegramVerified(gc *gin.Context) { // @tags Other func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { code := gc.Param("invCode") - if _, ok := app.storage.invites[code]; !ok { + if _, ok := app.storage.GetInvitesKey(code); !ok { respondBool(401, false, gc) return } @@ -484,7 +484,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { // @tags Other func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { code := gc.Param("invCode") - if _, ok := app.storage.invites[code]; !ok { + if _, ok := app.storage.GetInvitesKey(code); !ok { respondBool(401, false, gc) return } @@ -513,7 +513,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) { return } code := gc.Param("invCode") - if _, ok := app.storage.invites[code]; !ok { + if _, ok := app.storage.GetInvitesKey(code); !ok { respondBool(401, false, gc) return } @@ -537,7 +537,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) { // @tags Other func (app *appContext) MatrixSendPIN(gc *gin.Context) { code := gc.Param("invCode") - if _, ok := app.storage.invites[code]; !ok { + if _, ok := app.storage.GetInvitesKey(code); !ok { respondBool(401, false, gc) return } @@ -575,7 +575,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) { // @tags Other func (app *appContext) MatrixCheckPIN(gc *gin.Context) { code := gc.Param("invCode") - if _, ok := app.storage.invites[code]; !ok { + if _, ok := app.storage.GetInvitesKey(code); !ok { app.debug.Println("Matrix: Invite code was invalid") respondBool(401, false, gc) return diff --git a/api-users.go b/api-users.go index 4a54e01..3b2e934 100644 --- a/api-users.go +++ b/api-users.go @@ -227,14 +227,10 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed { claims := jwt.MapClaims{ - "valid": true, - "invite": req.Code, - "email": req.Email, - "username": req.Username, - "password": req.Password, - "telegramPIN": req.TelegramPIN, - "exp": time.Now().Add(time.Hour * 12).Unix(), - "type": "confirmation", + "valid": true, + "invite": req.Code, + "exp": time.Now().Add(30 * time.Minute).Unix(), + "type": "confirmation", } tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) @@ -246,10 +242,12 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } - inv := app.storage.invites[req.Code] - inv.Keys = append(inv.Keys, key) - app.storage.invites[req.Code] = inv - app.storage.storeInvites() + inv, _ := app.storage.GetInvitesKey(req.Code) + if inv.ConfirmationKeys == nil { + inv.ConfirmationKeys = map[string]newUserDTO{} + } + inv.ConfirmationKeys[key] = req + app.storage.SetInvitesKey(req.Code, inv) f = func(gc *gin.Context) { app.debug.Printf("%s: Email confirmation required", req.Code) respond(401, "confirmEmail", gc) @@ -276,7 +274,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } app.storage.loadProfiles() - invite := app.storage.invites[req.Code] + invite, _ := app.storage.GetInvitesKey(req.Code) app.checkInvite(req.Code, true, req.Username) if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) { for address, settings := range invite.Notify { diff --git a/storage.go b/storage.go index 5d66d0b..415c798 100644 --- a/storage.go +++ b/storage.go @@ -167,6 +167,39 @@ func (st *Storage) DeleteMatrixKey(k string) { st.matrixLock.Unlock() } +// GetInvites returns a copy of the store. +func (st *Storage) GetInvites() Invites { + if st.invites == nil { + st.invites = Invites{} + } + return st.invites +} + +// GetInvitesKey returns the value stored in the store's key. +func (st *Storage) GetInvitesKey(k string) (Invite, bool) { + v, ok := st.invites[k] + return v, ok +} + +// SetInvitesKey stores value v in key k. +func (st *Storage) SetInvitesKey(k string, v Invite) { + st.invitesLock.Lock() + if st.invites == nil { + st.invites = Invites{} + } + st.invites[k] = v + st.storeInvites() + st.invitesLock.Unlock() +} + +// DeleteInvitesKey deletes value at key k. +func (st *Storage) DeleteInvitesKey(k string) { + st.invitesLock.Lock() + delete(st.invites, k) + st.storeInvites() + st.invitesLock.Unlock() +} + type TelegramUser struct { ChatID int64 Username string @@ -241,12 +274,12 @@ type Invite struct { UserMinutes int `json:"user-minutes,omitempty"` SendTo string `json:"email"` // Used to be stored as formatted time, now as Unix. - UsedBy [][]string `json:"used-by"` - Notify map[string]map[string]bool `json:"notify"` - Profile string `json:"profile"` - Label string `json:"label,omitempty"` - Keys []string `json:"keys,omitempty"` - Captchas map[string]*captcha.Data // Map of Captcha IDs to answers + UsedBy [][]string `json:"used-by"` + Notify map[string]map[string]bool `json:"notify"` + Profile string `json:"profile"` + Label string `json:"label,omitempty"` + ConfirmationKeys map[string]newUserDTO `json:"-"` // map of JWT confirmation keys to their original requests + Captchas map[string]*captcha.Data // Map of Captcha IDs to answers } type Lang struct { @@ -932,14 +965,10 @@ func (st *Storage) loadLangTelegram(filesystems ...fs.FS) error { type Invites map[string]Invite func (st *Storage) loadInvites() error { - st.invitesLock.Lock() - defer st.invitesLock.Unlock() return loadJSON(st.invite_path, &st.invites) } func (st *Storage) storeInvites() error { - st.invitesLock.Lock() - defer st.invitesLock.Unlock() return storeJSON(st.invite_path, st.invites) } diff --git a/views.go b/views.go index 6c145e2..8ce557b 100644 --- a/views.go +++ b/views.go @@ -342,7 +342,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) { func (app *appContext) GetCaptcha(gc *gin.Context) { code := gc.Param("invCode") captchaID := gc.Param("captchaID") - inv, ok := app.storage.invites[code] + inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ "cssClass": app.cssClass, @@ -376,7 +376,7 @@ func (app *appContext) GetCaptcha(gc *gin.Context) { // @tags Users func (app *appContext) GenCaptcha(gc *gin.Context) { code := gc.Param("invCode") - inv, ok := app.storage.invites[code] + inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ "cssClass": app.cssClass, @@ -395,8 +395,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) { } captchaID := genAuthToken() inv.Captchas[captchaID] = capt - app.storage.invites[code] = inv - app.storage.storeInvites() + app.storage.SetInvitesKey(code, inv) gc.JSON(200, genCaptchaDTO{captchaID}) return } @@ -405,7 +404,7 @@ func (app *appContext) verifyCaptcha(code, id, text string) bool { reCAPTCHA := app.config.Section("captcha").Key("recaptcha").MustBool(false) if !reCAPTCHA { // internal CAPTCHA - inv, ok := app.storage.invites[code] + inv, ok := app.storage.GetInvitesKey(code) if !ok || inv.Captchas == nil { app.debug.Printf("Couldn't find invite \"%s\"", code) return false @@ -472,7 +471,7 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) { code := gc.Param("invCode") captchaID := gc.Param("captchaID") text := gc.Param("text") - inv, ok := app.storage.invites[code] + inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ "cssClass": app.cssClass, @@ -503,7 +502,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang) /* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */ // if app.checkInvite(code, false, "") { - inv, ok := app.storage.invites[code] + inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ "cssClass": app.cssClass, @@ -513,15 +512,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { return } if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { - validKey := false - keyIndex := -1 - for i, k := range inv.Keys { - if k == key { - validKey = true - keyIndex = i - break - } - } + req, ok := inv.ConfirmationKeys[key] fail := func() { gcHTML(gc, 404, "404.html", gin.H{ "cssClass": app.cssClass, @@ -529,7 +520,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } - if !validKey { + if !ok { fail() return } @@ -540,24 +531,12 @@ func (app *appContext) InviteProxy(gc *gin.Context) { return } claims, ok := token.Claims.(jwt.MapClaims) - expiryUnix := int64(claims["exp"].(float64)) - if err != nil { - fail() - app.err.Printf("Failed to parse key expiry: %s", err) - return - } - expiry := time.Unix(expiryUnix, 0) + expiry := time.Unix(int64(claims["exp"].(float64)), 0) if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) { fail() app.debug.Printf("Invalid key") return } - req := newUserDTO{ - Email: claims["email"].(string), - Username: claims["username"].(string), - Password: claims["password"].(string), - Code: claims["invite"].(string), - } _, success := app.newUser(req, true) if !success { fail() @@ -575,15 +554,17 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "jfLink": jfLink, }) } - inv, ok := app.storage.invites[code] + inv, ok := app.storage.GetInvitesKey(code) if ok { - l := len(inv.Keys) - inv.Keys[l-1], inv.Keys[keyIndex] = inv.Keys[keyIndex], inv.Keys[l-1] - app.storage.invites[code] = inv + delete(inv.ConfirmationKeys, key) + app.storage.SetInvitesKey(code, inv) } return } - email := app.storage.invites[code].SendTo + email := "" + if invite, ok := app.storage.GetInvitesKey(code); ok { + email = invite.SendTo + } if strings.Contains(email, "Failed") || !strings.Contains(email, "@") { email = "" } From 14c18bd66812fc7009ee633e70bd60acbd15ab67 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 21:14:41 +0100 Subject: [PATCH 41/54] form: rework email confirmation realized half the info from the signup form wasnt being stored in the JWT used to create the account after email confirmation, and instead of adding them, the -whole request- from the browser is stored temporarily by the server, indexed by a smaller JWT that only includes the invite code. Someone complained on reddit about me storing the password in the JWT a while back, and although security-wise that isn't an issue (only the server can decrypt the token), it doesn't happen anymore. Happy? --- api-users.go | 15 ++++++++++----- main.go | 45 ++++++++++++++++++++++++--------------------- storage.go | 11 +++++------ views.go | 27 ++++++++++++++++++++------- 4 files changed, 59 insertions(+), 39 deletions(-) diff --git a/api-users.go b/api-users.go index 3b2e934..b73f591 100644 --- a/api-users.go +++ b/api-users.go @@ -242,12 +242,17 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } - inv, _ := app.storage.GetInvitesKey(req.Code) - if inv.ConfirmationKeys == nil { - inv.ConfirmationKeys = map[string]newUserDTO{} + if app.ConfirmationKeys == nil { + app.ConfirmationKeys = map[string]map[string]newUserDTO{} } - inv.ConfirmationKeys[key] = req - app.storage.SetInvitesKey(req.Code, inv) + cKeys, ok := app.ConfirmationKeys[req.Code] + if !ok { + cKeys = map[string]newUserDTO{} + } + cKeys[key] = req + app.confirmationKeysLock.Lock() + app.ConfirmationKeys[req.Code] = cKeys + app.confirmationKeysLock.Unlock() f = func(gc *gin.Context) { app.debug.Printf("%s: Email confirmation required", req.Code) respond(401, "confirmEmail", gc) diff --git a/main.go b/main.go index 40ce405..79643cd 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "syscall" "time" @@ -90,27 +91,29 @@ type appContext struct { adminUsers []User invalidTokens []string // Keeping jf name because I can't think of a better one - jf *mediabrowser.MediaBrowser - authJf *mediabrowser.MediaBrowser - ombi *ombi.Ombi - datePattern string - timePattern string - storage Storage - validator Validator - email *Emailer - telegram *TelegramDaemon - discord *DiscordDaemon - matrix *MatrixDaemon - info, debug, err *logger.Logger - host string - port int - version string - URLBase string - updater *Updater - newUpdate bool // Whether whatever's in update is new. - tag Tag - update Update - internalPWRs map[string]InternalPWR + jf *mediabrowser.MediaBrowser + authJf *mediabrowser.MediaBrowser + ombi *ombi.Ombi + datePattern string + timePattern string + storage Storage + validator Validator + email *Emailer + telegram *TelegramDaemon + discord *DiscordDaemon + matrix *MatrixDaemon + info, debug, err *logger.Logger + host string + port int + version string + URLBase string + updater *Updater + newUpdate bool // Whether whatever's in update is new. + tag Tag + update Update + internalPWRs map[string]InternalPWR + ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request + confirmationKeysLock sync.Mutex } func generateSecret(length int) (string, error) { diff --git a/storage.go b/storage.go index 415c798..873d6c4 100644 --- a/storage.go +++ b/storage.go @@ -274,12 +274,11 @@ type Invite struct { UserMinutes int `json:"user-minutes,omitempty"` SendTo string `json:"email"` // Used to be stored as formatted time, now as Unix. - UsedBy [][]string `json:"used-by"` - Notify map[string]map[string]bool `json:"notify"` - Profile string `json:"profile"` - Label string `json:"label,omitempty"` - ConfirmationKeys map[string]newUserDTO `json:"-"` // map of JWT confirmation keys to their original requests - Captchas map[string]*captcha.Data // Map of Captcha IDs to answers + UsedBy [][]string `json:"used-by"` + Notify map[string]map[string]bool `json:"notify"` + Profile string `json:"profile"` + Label string `json:"label,omitempty"` + Captchas map[string]*captcha.Data // Map of Captcha IDs to answers } type Lang struct { diff --git a/views.go b/views.go index 8ce557b..5a20ae2 100644 --- a/views.go +++ b/views.go @@ -512,7 +512,6 @@ func (app *appContext) InviteProxy(gc *gin.Context) { return } if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { - req, ok := inv.ConfirmationKeys[key] fail := func() { gcHTML(gc, 404, "404.html", gin.H{ "cssClass": app.cssClass, @@ -520,6 +519,18 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } + var req newUserDTO + if app.ConfirmationKeys == nil { + fail() + return + } + + invKeys, ok := app.ConfirmationKeys[code] + if !ok { + fail() + return + } + req, ok = invKeys[key] if !ok { fail() return @@ -537,8 +548,11 @@ func (app *appContext) InviteProxy(gc *gin.Context) { app.debug.Printf("Invalid key") return } - _, success := app.newUser(req, true) + f, success := app.newUser(req, true) if !success { + app.err.Printf("Failed to create new user") + // Not meant for us. Calling this will be a mess, but at least it might give us some information. + f(gc) fail() return } @@ -554,11 +568,10 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "jfLink": jfLink, }) } - inv, ok := app.storage.GetInvitesKey(code) - if ok { - delete(inv.ConfirmationKeys, key) - app.storage.SetInvitesKey(code, inv) - } + delete(invKeys, key) + app.confirmationKeysLock.Lock() + app.ConfirmationKeys[code] = invKeys + app.confirmationKeysLock.Unlock() return } email := "" From 8113f794ab46e9899a386fd907623e282b4d590e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 Jun 2023 21:22:05 +0100 Subject: [PATCH 42/54] form: fix confirmation success page css --- html/invalidCode.html | 2 +- views.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/html/invalidCode.html b/html/invalidCode.html index cfc5ef8..15d02f5 100644 --- a/html/invalidCode.html +++ b/html/invalidCode.html @@ -1,7 +1,7 @@ - + {{ template "header.html" . }} Invalid Code - jfa-go diff --git a/views.go b/views.go index 5a20ae2..cf0a836 100644 --- a/views.go +++ b/views.go @@ -345,6 +345,7 @@ func (app *appContext) GetCaptcha(gc *gin.Context) { inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -379,6 +380,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) { inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -474,6 +476,7 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) { inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -505,6 +508,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { inv, ok := app.storage.GetInvitesKey(code) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -514,6 +518,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { fail := func() { gcHTML(gc, 404, "404.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -561,7 +566,9 @@ func (app *appContext) InviteProxy(gc *gin.Context) { gc.Redirect(301, jfLink) } else { gcHTML(gc, http.StatusOK, "create-success.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, + "cssVersion": cssVersion, "strings": app.storage.lang.User[lang].Strings, "successMessage": app.config.Section("ui").Key("success_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -651,6 +658,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { func (app *appContext) NoRouteHandler(gc *gin.Context) { app.pushResources(gc, OtherPage) gcHTML(gc, 404, "404.html", gin.H{ + "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), From 4f298bbc8cdd476cc4c86cb7315ea340508ab42d Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 09:41:41 +0100 Subject: [PATCH 43/54] userpage: add "back to admin" button --- css/base.css | 9 +++++++++ html/user.html | 1 + ts/user.ts | 3 +++ 3 files changed, 13 insertions(+) diff --git a/css/base.css b/css/base.css index 5599eb3..3376742 100644 --- a/css/base.css +++ b/css/base.css @@ -487,6 +487,15 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) { color: var(--color-urge-200); } +a.button, +a.button:link, +a.button:visited, +a.button:focus, +a.buton:hover { + color: var(--color-content) !important; +} + + .link-center { display: block; text-align: center; diff --git a/html/user.html b/html/user.html index c24eb83..3f78f8b 100644 --- a/html/user.html +++ b/html/user.html @@ -68,6 +68,7 @@ {{ .strings.logout }} +
{{ .strings.admin }}
diff --git a/ts/user.ts b/ts/user.ts index 84e0867..ed71747 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -391,9 +391,12 @@ document.addEventListener("details-reload", () => { expiryCard.expiry = details.expiry; + const adminBackButton = document.getElementById("admin-back-button") as HTMLAnchorElement; + adminBackButton.href = window.location.href.replace("my/account", ""); let messageCard = document.getElementById("card-message"); if (details.accounts_admin) { + adminBackButton.classList.remove("unfocused"); if (typeof(messageCard) == "undefined" || messageCard == null) { messageCard = document.createElement("div"); messageCard.classList.add("card", "@low", "dark:~d_neutral", "content"); From db97c3b2d4042c844a8379d80d60e43e69702a6c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 10:11:56 +0100 Subject: [PATCH 44/54] form: add notice about userpage on success modal, userpage title uses new strings in the form lang section. --- html/form-base.html | 1 + html/form.html | 1 + html/user.html | 2 +- lang/form/en-us.json | 4 +++- ts/form.ts | 5 +++++ views.go | 1 + 6 files changed, 12 insertions(+), 2 deletions(-) diff --git a/html/form-base.html b/html/form-base.html index b5f2e2a..1675c56 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -29,6 +29,7 @@ window.captcha = {{ .captcha }}; window.reCAPTCHA = {{ .reCAPTCHA }}; window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}"; + window.userPageEnabled = {{ .userPageEnabled }}; {{ if .passwordReset }} diff --git a/html/form.html b/html/form.html index ef2b888..15e83e2 100644 --- a/html/form.html +++ b/html/form.html @@ -17,6 +17,7 @@
{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}

{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}

+ {{ if .userPageEnabled }}{{ end }} {{ .strings.continue }}
diff --git a/html/user.html b/html/user.html index 3f78f8b..4c62619 100644 --- a/html/user.html +++ b/html/user.html @@ -24,7 +24,7 @@ window.matrixUserID = "{{ .matrixUser }}"; {{ template "header.html" . }} - {{ .lang.Strings.pageTitle }} + {{ .lang.Strings.myAccount }} +
diff --git a/html/login-modal.html b/html/login-modal.html index da49e20..2f9c3fb 100644 --- a/html/login-modal.html +++ b/html/login-modal.html @@ -14,6 +14,11 @@
diff --git a/html/user.html b/html/user.html index 4c62619..b709969 100644 --- a/html/user.html +++ b/html/user.html @@ -6,6 +6,7 @@ window.notificationsEnabled = {{ .notifications }}; window.ombiEnabled = {{ .ombiEnabled }}; window.langFile = JSON.parse({{ .language }}); + window.pwrEnabled = {{ .pwrEnabled }}; window.linkResetEnabled = {{ .linkResetEnabled }}; window.language = "{{ .langName }}"; window.telegramEnabled = {{ .telegramEnabled }}; @@ -43,6 +44,30 @@
+ {{ if .pwrEnabled }} + + {{ end }} {{ template "login-modal.html" . }} {{ template "account-linking.html" . }}
@@ -68,6 +93,8 @@ {{ .strings.logout }} +
+
diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 266216f..43539db 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -38,7 +38,8 @@ "expiry": "Expiry", "add": "Add", "edit": "Edit", - "delete": "Delete" + "delete": "Delete", + "myAccount": "My Account" }, "notifications": { "errorLoginBlank": "The username and/or password were left blank.", diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 946af1f..5b30b33 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -27,7 +27,11 @@ "customMessagePlaceholderHeader": "Customize this card", "customMessagePlaceholderContent": "Click the user page edit button in settings to customize this card, or show one on the login screen, and don't worry, the user can't see this.", "userPageSuccessMessage": "You can see and change details about your account later on the {myAccount} page.", - "myAccount": "My Account" + "resetPassword": "Reset Password", + "resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.", + "resetPasswordThroughLink": "To reset your password, enter your email address or a linked contact method username, and submit. A link will be sent to reset your password.", + "resetSent": "Reset Sent.", + "resetSentDescription": "If an account with the given contact method exists, a password reset link has been sent to all contact methods available. The code will expire in 30 minutes." }, "notifications": { "errorUserExists": "User already exists.", diff --git a/pwreset.go b/pwreset.go index 22e9a74..be3d3fb 100644 --- a/pwreset.go +++ b/pwreset.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "os" "strings" "time" @@ -25,6 +26,18 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) { return pwr, nil } +// GenResetLink generates and returns a password reset link. +func (app *appContext) GenResetLink(pin string) (string, error) { + url := app.config.Section("password_resets").Key("url_base").String() + var pinLink string + if url == "" { + return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.") + } + // Strip /invite from end of this URL, ik it's ugly. + pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin) + return pinLink, nil +} + func (app *appContext) StartPWR() { app.info.Println("Starting password reset daemon") path := app.config.Section("password_resets").Key("watch_directory").String() diff --git a/router.go b/router.go index f0fd890..ef6254b 100644 --- a/router.go +++ b/router.go @@ -148,6 +148,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.GET(p+"/my/token/login", app.getUserTokenLogin) router.GET(p+"/my/token/refresh", app.getUserTokenRefresh) router.GET(p+"/my/confirm/:jwt", app.ConfirmMyAction) + router.POST(p+"/my/password/reset/:address", app.ResetMyPassword) } } if *SWAGGER { diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 4c174da..cae4f54 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -110,6 +110,7 @@ declare interface Modals { discord: Modal; matrix: Modal; sendPWR?: Modal; + pwr?: Modal; logs: Modal; email?: Modal; } diff --git a/ts/user.ts b/ts/user.ts index ed71747..03e8f28 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -16,6 +16,7 @@ interface userWindow extends Window { discordInviteLink: boolean; matrixUserID: string; discordSendPINMessage: string; + pwrEnabled: string; } declare var window: userWindow; @@ -44,10 +45,38 @@ window.modals = {} as Modals; if (window.matrixEnabled) { window.modals.matrix = new Modal(document.getElementById("modal-matrix"), false); } + if (window.pwrEnabled) { + window.modals.pwr = new Modal(document.getElementById("modal-pwr"), false); + window.modals.pwr.onclose = () => { + window.modals.login.show(); + }; + const resetButton = document.getElementById("modal-login-pwr"); + resetButton.onclick = () => { + window.modals.login.close(); + window.modals.pwr.show(); + } + } })(); window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5); +if (window.pwrEnabled && window.linkResetEnabled) { + const submitButton = document.getElementById("pwr-submit"); + const input = document.getElementById("pwr-address") as HTMLInputElement; + submitButton.onclick = () => _post("/my/password/reset/" + input.value, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status != 204) { + window.notifications.customError("unkownError", window.lang.notif("errorUnknown"));; + window.modals.pwr.close(); + return; + } + window.modals.pwr.modal.querySelector(".heading").textContent = window.lang.strings("resetSent"); + window.modals.pwr.modal.querySelector(".content").textContent = window.lang.strings("resetSentDescription"); + submitButton.classList.add("unfocused"); + input.classList.add("unfocused"); + }); +} + const grid = document.querySelector(".grid"); var rootCard = document.getElementById("card-user"); var contactCard = document.getElementById("card-contact"); diff --git a/views.go b/views.go index 62f2ed1..43f6fb1 100644 --- a/views.go +++ b/views.go @@ -157,6 +157,7 @@ func (app *appContext) AdminPage(gc *gin.Context) { "jellyfinLogin": app.jellyfinLogin, "jfAdminOnly": jfAdminOnly, "jfAllowAll": jfAllowAll, + "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false), }) } @@ -177,6 +178,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "discordEnabled": discordEnabled, "matrixEnabled": matrixEnabled, "ombiEnabled": ombiEnabled, + "pwrEnabled": app.config.Section("password_resets").Key("enabled").MustBool(false), "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false), "notifications": notificationsEnabled, "username": !app.config.Section("email").Key("no_username").MustBool(false), @@ -184,6 +186,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "validationStrings": app.storage.lang.User[lang].ValidationStrings, "language": app.storage.lang.User[lang].JSON, "langName": lang, + "jfLink": app.config.Section("ui").Key("redirect_url").String(), } if telegramEnabled { data["telegramUsername"] = app.telegram.username From 3ec3e9672e5169875640ea9ae83457843e3c9011 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 12:25:00 +0100 Subject: [PATCH 46/54] userpage: time-pad pwr request for ambiguity the user shouldn't know if the reset has actually been sent (i.e. if an account with the given contact address exists), so the backend response is always sent after 1 second. --- api-userpage.go | 27 +++++++++++++++++++++++---- ts/user.ts | 28 ++++++++++++++++------------ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index 189b8aa..3097b5e 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -487,9 +487,15 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { // @Router /my/password/reset/{address} [post] // @tags Users func (app *appContext) ResetMyPassword(gc *gin.Context) { + // All requests should take 1 second, to make it harder to tell if a success occured or not. + timerWait := make(chan bool) + cancel := time.AfterFunc(1*time.Second, func() { + timerWait <- true + }) address := gc.Param("address") if address == "" { app.debug.Println("Ignoring empty request for PWR") + cancel.Stop() respondBool(400, false, gc) return } @@ -499,13 +505,20 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) { jfID := app.reverseUserSearch(address) if jfID == "" { app.debug.Printf("Ignoring PWR request: User not found") - respondBool(204, true, gc) + + for range timerWait { + respondBool(204, true, gc) + return + } return } pwr, err = app.GenInternalReset(jfID) if err != nil { app.err.Printf("Failed to get user from Jellyfin: %v", err) - respondBool(500, false, gc) + for range timerWait { + respondBool(204, true, gc) + return + } return } if app.internalPWRs == nil { @@ -523,12 +536,18 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) { ) if err != nil { app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err) - respondBool(500, false, gc) + for range timerWait { + respondBool(204, true, gc) + return + } return } else if err := app.sendByID(msg, jfID); err != nil { app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err) } else { app.info.Printf("Sent password reset message to \"%s\"", address) } - respondBool(204, true, gc) + for range timerWait { + respondBool(204, true, gc) + return + } } diff --git a/ts/user.ts b/ts/user.ts index 03e8f28..ac49b7e 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -63,18 +63,22 @@ window.notifications = new notificationBox(document.getElementById('notification if (window.pwrEnabled && window.linkResetEnabled) { const submitButton = document.getElementById("pwr-submit"); const input = document.getElementById("pwr-address") as HTMLInputElement; - submitButton.onclick = () => _post("/my/password/reset/" + input.value, null, (req: XMLHttpRequest) => { - if (req.readyState != 4) return; - if (req.status != 204) { - window.notifications.customError("unkownError", window.lang.notif("errorUnknown"));; - window.modals.pwr.close(); - return; - } - window.modals.pwr.modal.querySelector(".heading").textContent = window.lang.strings("resetSent"); - window.modals.pwr.modal.querySelector(".content").textContent = window.lang.strings("resetSentDescription"); - submitButton.classList.add("unfocused"); - input.classList.add("unfocused"); - }); + submitButton.onclick = () => { + toggleLoader(submitButton); + _post("/my/password/reset/" + input.value, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + toggleLoader(submitButton); + if (req.status != 204) { + window.notifications.customError("unkownError", window.lang.notif("errorUnknown"));; + window.modals.pwr.close(); + return; + } + window.modals.pwr.modal.querySelector(".heading").textContent = window.lang.strings("resetSent"); + window.modals.pwr.modal.querySelector(".content").textContent = window.lang.strings("resetSentDescription"); + submitButton.classList.add("unfocused"); + input.classList.add("unfocused"); + }); + }; } const grid = document.querySelector(".grid"); From 22c91be12770473fbed2e0566589a43fc290452a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 12:33:40 +0100 Subject: [PATCH 47/54] userpage: make pwr accept username too --- api-userpage.go | 4 ++-- config/config-base.json | 2 +- email.go | 8 +++++++- html/user.html | 2 +- lang/form/en-us.json | 4 ++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index 3097b5e..f129a08 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -478,7 +478,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { respondBool(200, true, gc) } -// @Summary Generate & send a password reset link if the given email/contact method exists. Doesn't give you any info about it's success. +// @Summary Generate & send a password reset link if the given username/email/contact method exists. Doesn't give you any info about it's success. // @Produce json // @Param address path string true "address/contact method associated w/ your account." // @Success 204 {object} boolResponse @@ -502,7 +502,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) { var pwr InternalPWR var err error - jfID := app.reverseUserSearch(address) + jfID := app.ReverseUserSearch(address) if jfID == "" { app.debug.Printf("Ignoring PWR request: User not found") diff --git a/config/config-base.json b/config/config-base.json index c0fb5f8..c8e08af 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -377,7 +377,7 @@ "order": [], "meta": { "name": "User Page", - "description": "Settings for the user page, which provides useful info and tools to users directly.", + "description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, or changing their password. Password resets can also be initiated from here, given a contact method or username. ", "depends_true": "ui|jellyfin_login" }, "settings": { diff --git a/email.go b/email.go index dda9428..4ee8f36 100644 --- a/email.go +++ b/email.go @@ -872,7 +872,13 @@ func (app *appContext) getAddressOrName(jfID string) string { return "" } -func (app *appContext) reverseUserSearch(address string) string { +// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username. +// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames. +func (app *appContext) ReverseUserSearch(address string) string { + user, status, err := app.jf.UserByName(address, false) + if status == 200 && err == nil { + return user.ID + } for id, email := range app.storage.GetEmails() { if strings.ToLower(address) == strings.ToLower(email.Addr) { return id diff --git a/html/user.html b/html/user.html index b709969..2c5cf5d 100644 --- a/html/user.html +++ b/html/user.html @@ -56,7 +56,7 @@ {{ end }}

- +
{{ if .linkResetEnabled }} diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 5b30b33..23a120c 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -29,9 +29,9 @@ "userPageSuccessMessage": "You can see and change details about your account later on the {myAccount} page.", "resetPassword": "Reset Password", "resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.", - "resetPasswordThroughLink": "To reset your password, enter your email address or a linked contact method username, and submit. A link will be sent to reset your password.", + "resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.", "resetSent": "Reset Sent.", - "resetSentDescription": "If an account with the given contact method exists, a password reset link has been sent to all contact methods available. The code will expire in 30 minutes." + "resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes." }, "notifications": { "errorUserExists": "User already exists.", From 3b3f37365a51b18fcf43fbfe638ba964824c954b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 12:39:13 +0100 Subject: [PATCH 48/54] userpage: autofill username in pwr modal --- ts/user.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ts/user.ts b/ts/user.ts index ac49b7e..8fef411 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -52,6 +52,9 @@ window.modals = {} as Modals; }; const resetButton = document.getElementById("modal-login-pwr"); resetButton.onclick = () => { + const usernameInput = document.getElementById("login-user") as HTMLInputElement; + const input = document.getElementById("pwr-address") as HTMLInputElement; + input.value = usernameInput.value; window.modals.login.close(); window.modals.pwr.show(); } From 4496e1d5099a63c4d81f04f26900fbe5853869df Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 17:35:34 +0100 Subject: [PATCH 49/54] pwr: ensure internal pwr pin is deleted after use --- api.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api.go b/api.go index 2aec453..53344eb 100644 --- a/api.go +++ b/api.go @@ -143,6 +143,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) { respondBool(status, false, gc) return } + delete(app.internalPWRs, req.PIN) } else { resp, status, err := app.jf.ResetPassword(req.PIN) if status != 200 || err != nil || !resp.Success { From 12ce669566e4443d352e4a7e29c5f2d06e8da883 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 18:31:16 +0100 Subject: [PATCH 50/54] userpage: add password change card, validation, rearrange page functionality not done yet, just comitting here because there were lots of adjustments to layout stuff, accomodating for most combinations of card presence/size. --- html/user.html | 40 +++++++++++++-- lang/form/en-us.json | 5 +- ts/form.ts | 29 +++++++---- ts/modules/validator.ts | 110 +++++++++++++++++++++++----------------- ts/pwr.ts | 16 ++++-- ts/user.ts | 70 +++++++++++++++++++------ views.go | 3 +- 7 files changed, 191 insertions(+), 82 deletions(-) diff --git a/html/user.html b/html/user.html index 2c5cf5d..d75b1b9 100644 --- a/html/user.html +++ b/html/user.html @@ -23,6 +23,7 @@ window.matrixEnabled = {{ .matrixEnabled }}; window.matrixRequired = {{ .matrixRequired }}; window.matrixUserID = "{{ .matrixUser }}"; + window.validationStrings = JSON.parse({{ .validationStrings }}); {{ template "header.html" . }} {{ .lang.Strings.myAccount }} @@ -113,10 +114,41 @@ {{ .strings.contactMethods }}
-
- {{ .strings.expiry }} - -
+
+
+ {{ .strings.changePassword }} +
+
+ {{ .strings.passwordRequirementsHeader }} +
    + {{ range $key, $value := .requirements }} +
  • + +
  • + {{ end }} +
+
+
+ + + + + + + + + {{ .strings.changePassword }} + +
+
+
+
+
+
+ {{ .strings.expiry }} + +
+
diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 23a120c..13ef671 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -8,6 +8,8 @@ "accountDetails": "Details", "emailAddress": "Email", "username": "Username", + "oldPassword": "Old Password", + "newPassword": "New Password", "password": "Password", "reEnterPassword": "Re-enter Password", "reEnterPasswordInvalid": "Passwords are not the same.", @@ -31,7 +33,8 @@ "resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.", "resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.", "resetSent": "Reset Sent.", - "resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes." + "resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes.", + "changePassword": "Change Password" }, "notifications": { "errorUserExists": "User already exists.", diff --git a/ts/form.ts b/ts/form.ts index 3cd4ba2..8707aa9 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -2,7 +2,7 @@ import { Modal } from "./modules/modal.js"; import { notificationBox, whichAnimationEvent } from "./modules/common.js"; import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; -import { initValidator } from "./modules/validator.js"; +import { Validator, ValidatorConf } from "./modules/validator.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; interface formWindow extends Window { @@ -69,7 +69,7 @@ if (window.telegramEnabled) { const radio = document.getElementById("contact-via-telegram") as HTMLInputElement; radio.parentElement.classList.remove("unfocused"); radio.checked = true; - validatorFunc(); + validator.validate(); } }; @@ -101,7 +101,7 @@ if (window.discordEnabled) { const radio = document.getElementById("contact-via-discord") as HTMLInputElement; radio.parentElement.classList.remove("unfocused") radio.checked = true; - validatorFunc(); + validator.validate(); } }; @@ -133,7 +133,7 @@ if (window.matrixEnabled) { const radio = document.getElementById("contact-via-matrix") as HTMLInputElement; radio.parentElement.classList.remove("unfocused"); radio.checked = true; - validatorFunc(); + validator.validate(); } }; @@ -162,7 +162,7 @@ if (window.userExpiryEnabled) { } const form = document.getElementById("form-create") as HTMLFormElement; -const submitButton = form.querySelector("input[type=submit]") as HTMLInputElement; +const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement; const submitSpan = form.querySelector("span.submit") as HTMLSpanElement; const submitText = submitSpan.textContent; let usernameField = document.getElementById("create-username") as HTMLInputElement; @@ -242,12 +242,19 @@ interface GreCAPTCHA { declare var grecaptcha: GreCAPTCHA -let r = initValidator(passwordField, rePasswordField, submitButton, submitSpan, baseValidator); -var requirements = r[0]; -var validatorFunc = r[1] as () => void; +let validatorConf: ValidatorConf = { + passwordField: passwordField, + rePasswordField: rePasswordField, + submitInput: submitInput, + submitButton: submitSpan, + validatorFunc: baseValidator +}; + +let validator = new Validator(validatorConf); +var requirements = validator.requirements; if (window.emailRequired) { - emailField.addEventListener("keyup", validatorFunc) + emailField.addEventListener("keyup", validator.validate) } interface respDTO { @@ -287,7 +294,7 @@ const genCaptcha = () => { if (window.captcha && !window.reCAPTCHA) { genCaptcha(); (document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha; - captchaInput.onkeyup = validatorFunc; + captchaInput.onkeyup = validator.validate; } const create = (event: SubmitEvent) => { @@ -386,6 +393,6 @@ const create = (event: SubmitEvent) => { }); }; -validatorFunc(); +validator.validate(); form.onsubmit = create; diff --git a/ts/modules/validator.ts b/ts/modules/validator.ts index 728a800..e535070 100644 --- a/ts/modules/validator.ts +++ b/ts/modules/validator.ts @@ -60,8 +60,21 @@ class Requirement { validate = (count: number) => { this.valid = (count >= this._minCount); } } -export function initValidator(passwordField: HTMLInputElement, rePasswordField: HTMLInputElement, submitButton: HTMLInputElement, submitSpan: HTMLSpanElement, validatorFunc?: (oncomplete: (valid: boolean) => void) => void): ({ [category: string]: Requirement }|(() => void))[] { - var defaultPwValStrings: pwValStrings = { +export interface ValidatorConf { + passwordField: HTMLInputElement; + rePasswordField: HTMLInputElement; + submitInput?: HTMLInputElement; + submitButton: HTMLSpanElement; + validatorFunc?: (oncomplete: (valid: boolean) => void) => void; +} + +export interface Validation { [name: string]: number } +export interface Requirements { [category: string]: Requirement }; + +export class Validator { + private _conf: ValidatorConf; + private _requirements: Requirements = {}; + private _defaultPwValStrings: pwValStrings = { length: { singular: "Must have at least {n} character", plural: "Must have at least {n} characters" @@ -82,39 +95,34 @@ export function initValidator(passwordField: HTMLInputElement, rePasswordField: singular: "Must have at least {n} special character", plural: "Must have at least {n} special characters" } + }; + + private _checkPasswords = () => { + return this._conf.passwordField.value == this._conf.rePasswordField.value; } - const checkPasswords = () => { - return passwordField.value == rePasswordField.value; - } - - const checkValidity = () => { - const pw = checkPasswords(); - validatorFunc((valid: boolean) => { + validate = () => { + const pw = this._checkPasswords(); + this._conf.validatorFunc((valid: boolean) => { if (pw && valid) { - rePasswordField.setCustomValidity(""); - submitButton.disabled = false; - submitSpan.removeAttribute("disabled"); + this._conf.rePasswordField.setCustomValidity(""); + if (this._conf.submitInput) this._conf.submitInput.disabled = false; + this._conf.submitButton.removeAttribute("disabled"); } else if (!pw) { - rePasswordField.setCustomValidity(window.invalidPassword); - submitButton.disabled = true; - submitSpan.setAttribute("disabled", ""); + this._conf.rePasswordField.setCustomValidity(window.invalidPassword); + if (this._conf.submitInput) this._conf.submitInput.disabled = true; + this._conf.submitButton.setAttribute("disabled", ""); } else { - rePasswordField.setCustomValidity(""); - submitButton.disabled = true; - submitSpan.setAttribute("disabled", ""); + this._conf.rePasswordField.setCustomValidity(""); + if (this._conf.submitInput) this._conf.submitInput.disabled = true; + this._conf.submitButton.setAttribute("disabled", ""); } }); }; - rePasswordField.addEventListener("keyup", checkValidity); - passwordField.addEventListener("keyup", checkValidity); - - - // Incredible code right here - const isInt = (s: string): boolean => { return (s in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); } - - const testStrings = (f: pwValString): boolean => { + private _isInt = (s: string): boolean => { return (s >= '0' && s <= '9'); } + + private _testStrings = (f: pwValString): boolean => { const testString = (s: string): boolean => { if (s == "" || !s.includes("{n}")) { return false; } return true; @@ -122,14 +130,12 @@ export function initValidator(passwordField: HTMLInputElement, rePasswordField: return testString(f.singular) && testString(f.plural); } - interface Validation { [name: string]: number } - - const validate = (s: string): Validation => { + private _validate = (s: string): Validation => { let v: Validation = {}; for (let criteria of ["length", "lowercase", "uppercase", "number", "special"]) { v[criteria] = 0; } v["length"] = s.length; for (let c of s) { - if (isInt(c)) { v["number"]++; } + if (this._isInt(c)) { v["number"]++; } else { const upper = c.toUpperCase(); if (upper == c.toLowerCase()) { v["special"]++; } @@ -141,27 +147,37 @@ export function initValidator(passwordField: HTMLInputElement, rePasswordField: } return v } - passwordField.addEventListener("keyup", () => { - const v = validate(passwordField.value); - for (let criteria in requirements) { - requirements[criteria].validate(v[criteria]); - } - }); - - var requirements: { [category: string]: Requirement } = {}; - - if (!window.validationStrings) { - window.validationStrings = defaultPwValStrings; - } else { + + private _bindRequirements = () => { for (let category in window.validationStrings) { - if (!testStrings(window.validationStrings[category])) { - window.validationStrings[category] = defaultPwValStrings[category]; + if (!this._testStrings(window.validationStrings[category])) { + window.validationStrings[category] = this._defaultPwValStrings[category]; } const el = document.getElementById("requirement-" + category); - if (el) { - requirements[category] = new Requirement(category, el as HTMLLIElement); + if (typeof(el) === 'undefined' || el == null) continue; + this._requirements[category] = new Requirement(category, el as HTMLLIElement); + } + }; + + get requirements(): Requirements { return this._requirements }; + + constructor(conf: ValidatorConf) { + this._conf = conf; + if (!(this._conf.validatorFunc)) { + this._conf.validatorFunc = (oncomplete: (valid: boolean) => void) => { oncomplete(true); }; + } + this._conf.rePasswordField.addEventListener("keyup", this.validate); + this._conf.passwordField.addEventListener("keyup", this.validate); + this._conf.passwordField.addEventListener("keyup", () => { + const v = this._validate(this._conf.passwordField.value); + for (let criteria in this._requirements) { + this._requirements[criteria].validate(v[criteria]); } + }); + if (!window.validationStrings) { + window.validationStrings = this._defaultPwValStrings; + } else { + this._bindRequirements(); } } - return [requirements, checkValidity] } diff --git a/ts/pwr.ts b/ts/pwr.ts index 83ee992..d5a40a0 100644 --- a/ts/pwr.ts +++ b/ts/pwr.ts @@ -1,5 +1,5 @@ import { Modal } from "./modules/modal.js"; -import { initValidator } from "./modules/validator.js"; +import { Validator, ValidatorConf } from "./modules/validator.js"; import { _post, addLoader, removeLoader } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; @@ -35,14 +35,22 @@ loadLangSelector("pwr"); declare var window: formWindow; const form = document.getElementById("form-create") as HTMLFormElement; -const submitButton = form.querySelector("input[type=submit]") as HTMLInputElement; +const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement; const submitSpan = form.querySelector("span.submit") as HTMLSpanElement; const passwordField = document.getElementById("create-password") as HTMLInputElement; const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement; window.successModal = new Modal(document.getElementById("modal-success"), true); -var requirements = initValidator(passwordField, rePasswordField, submitButton, submitSpan) +let validatorConf: ValidatorConf = { + passwordField: passwordField, + rePasswordField: rePasswordField, + submitInput: submitInput, + submitButton: submitSpan +}; + +var validator = new Validator(validatorConf); +var requirements = validator.requirements; interface sendDTO { pin: string; @@ -81,3 +89,5 @@ form.onsubmit = (event: Event) => { } }, true); }; + +validator.validate(); diff --git a/ts/user.ts b/ts/user.ts index 8fef411..a48c0f2 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -4,6 +4,7 @@ import { Modal } from "./modules/modal.js"; import { _get, _post, _delete, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; +import { Validator, ValidatorConf } from "./modules/validator.js"; interface userWindow extends Window { jellyfinID: string; @@ -88,6 +89,7 @@ const grid = document.querySelector(".grid"); var rootCard = document.getElementById("card-user"); var contactCard = document.getElementById("card-contact"); var statusCard = document.getElementById("card-status"); +var passwordCard = document.getElementById("card-password"); interface MyDetailsContactMethod { value: string; @@ -385,6 +387,29 @@ const matrixConf: MatrixConfiguration = { let matrix = new Matrix(matrixConf); +const oldPasswordField = document.getElementById("user-old-password") as HTMLInputElement; +const newPasswordField = document.getElementById("user-new-password") as HTMLInputElement; +const rePasswordField = document.getElementById("user-reenter-new-password") as HTMLInputElement; +const changePasswordButton = document.getElementById("user-password-submit") as HTMLSpanElement; + +let baseValidator = (oncomplete: (valid: boolean) => void): void => { + if (oldPasswordField.value.length == 0) return oncomplete(false); + oncomplete(true); +}; + +let validatorConf: ValidatorConf = { + passwordField: newPasswordField, + rePasswordField: rePasswordField, + submitButton: changePasswordButton, + validatorFunc: baseValidator +}; + +let validator = new Validator(validatorConf); +let requirements = validator.requirements; + +oldPasswordField.addEventListener("keyup", validator.validate); +// FIXME: Submit & Validate + document.addEventListener("details-reload", () => { _get("/my/details", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { @@ -448,20 +473,10 @@ document.addEventListener("details-reload", () => { } if (typeof(messageCard) != "undefined" && messageCard != null) { - let largestNonMessageCardHeight = 0; - const cards = grid.querySelectorAll(".card") as NodeListOf; - for (let i = 0; i < cards.length; i++) { - if (cards[i].id == "card-message") continue; - if (computeRealHeight(cards[i]) > largestNonMessageCardHeight) { - largestNonMessageCardHeight = computeRealHeight(cards[i]); - } - } - - - let rowSpan = Math.ceil(computeRealHeight(messageCard) / largestNonMessageCardHeight); - - if (rowSpan > 0) - messageCard.style.gridRow = `span ${rowSpan}`; + setBestRowSpan(messageCard, false); + // contactCard.querySelector(".content").classList.add("h-100"); + } else if (!statusCard.classList.contains("unfocused")) { + setBestRowSpan(passwordCard, true); } } }); @@ -474,11 +489,36 @@ login.onLogin = () => { document.dispatchEvent(new CustomEvent("details-reload")); }; +const setBestRowSpan = (el: HTMLElement, setOnParent: boolean) => { + let largestNonMessageCardHeight = 0; + const cards = grid.querySelectorAll(".card") as NodeListOf; + for (let i = 0; i < cards.length; i++) { + if (cards[i].id == el.id) continue; + if (computeRealHeight(cards[i]) > largestNonMessageCardHeight) { + largestNonMessageCardHeight = computeRealHeight(cards[i]); + } + } + + let rowSpan = Math.ceil(computeRealHeight(el) / largestNonMessageCardHeight); + + if (rowSpan > 0) + (setOnParent ? el.parentElement : el).style.gridRow = `span ${rowSpan}`; +}; + const computeRealHeight = (el: HTMLElement): number => { let children = el.children as HTMLCollectionOf; let total = 0; for (let i = 0; i < children.length; i++) { - total += children[i].offsetHeight; + // Cope with the contact method card expanding to fill, by counting each contact method individually + if (el.id == "card-contact" && children[i].classList.contains("content")) { + // console.log("FOUND CARD_CONTACT, OG:", total + children[i].offsetHeight); + for (let j = 0; j < children[i].children.length; j++) { + total += (children[i].children[j] as HTMLElement).offsetHeight; + } + // console.log("NEW:", total); + } else { + total += children[i].offsetHeight; + } } return total; } diff --git a/views.go b/views.go index 43f6fb1..2570469 100644 --- a/views.go +++ b/views.go @@ -183,10 +183,11 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "notifications": notificationsEnabled, "username": !app.config.Section("email").Key("no_username").MustBool(false), "strings": app.storage.lang.User[lang].Strings, - "validationStrings": app.storage.lang.User[lang].ValidationStrings, + "validationStrings": app.storage.lang.User[lang].validationStringsJSON, "language": app.storage.lang.User[lang].JSON, "langName": lang, "jfLink": app.config.Section("ui").Key("redirect_url").String(), + "requirements": app.validator.getCriteria(), } if telegramEnabled { data["telegramUsername"] = app.telegram.username From 97db4d714ab93142570184dbd7147d1d525cfa53 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 20:54:52 +0100 Subject: [PATCH 51/54] userpage: implement change password functionality --- api-userpage.go | 77 ++++++++++++++++++++++++++++++++++++++--- api-users.go | 1 + html/form.html | 2 +- lang/form/en-us.json | 2 ++ models.go | 5 +++ router.go | 1 + ts/form.ts | 13 +++---- ts/modules/validator.ts | 5 +++ ts/user.ts | 30 +++++++++++++--- 9 files changed, 118 insertions(+), 18 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index f129a08..413f8cf 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -275,6 +275,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) { // @Failure 500 {object} boolResponse // @Param invCode path string true "invite Code" // @Router /my/discord/invite [get] +// @Security Bearer // @tags User Page func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { if app.discord.inviteChannelName == "" { @@ -295,6 +296,7 @@ func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { // @Failure 400 {object} stringResponse // Param service path string true "discord/telegram" // @Router /my/pin/{service} [get] +// @Security Bearer // @tags User Page func (app *appContext) GetMyPIN(gc *gin.Context) { service := gc.Param("service") @@ -319,6 +321,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) { // @Failure 401 {object} boolResponse // @Param pin path string true "PIN code to check" // @Router /my/discord/verified/{pin} [get] +// @Security Bearer // @tags User Page func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") @@ -347,6 +350,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { // @Failure 401 {object} boolResponse // @Param pin path string true "PIN code to check" // @Router /my/telegram/verified/{pin} [get] +// @Security Bearer // @tags User Page func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") @@ -386,6 +390,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { // @Failure 500 {object} boolResponse // @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID." // @Router /my/matrix/user [post] +// @Security Bearer // @tags User Page func (app *appContext) MatrixSendMyPIN(gc *gin.Context) { var req MatrixSendPINDTO @@ -419,6 +424,7 @@ func (app *appContext) MatrixSendMyPIN(gc *gin.Context) { // @Param invCode path string true "invite Code" // @Param userID path string true "Matrix User ID" // @Router /my/matrix/verified/{userID}/{pin} [get] +// @Security Bearer // @tags User Page func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) { userID := gc.Param("userID") @@ -452,7 +458,8 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) { // @Produce json // @Success 200 {object} boolResponse // @Router /my/discord [delete] -// @Tags Users +// @Security Bearer +// @Tags User Page func (app *appContext) UnlinkMyDiscord(gc *gin.Context) { app.storage.DeleteDiscordKey(gc.GetString("jfId")) respondBool(200, true, gc) @@ -462,7 +469,8 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) { // @Produce json // @Success 200 {object} boolResponse // @Router /my/telegram [delete] -// @Tags Users +// @Security Bearer +// @Tags User Page func (app *appContext) UnlinkMyTelegram(gc *gin.Context) { app.storage.DeleteTelegramKey(gc.GetString("jfId")) respondBool(200, true, gc) @@ -472,7 +480,8 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) { // @Produce json // @Success 200 {object} boolResponse // @Router /my/matrix [delete] -// @Tags Users +// @Security Bearer +// @Tags User Page func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { app.storage.DeleteMatrixKey(gc.GetString("jfId")) respondBool(200, true, gc) @@ -485,7 +494,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Router /my/password/reset/{address} [post] -// @tags Users +// @Tags User Page func (app *appContext) ResetMyPassword(gc *gin.Context) { // All requests should take 1 second, to make it harder to tell if a success occured or not. timerWait := make(chan bool) @@ -551,3 +560,63 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) { return } } + +// @Summary Change your password, given the old one and the new one. +// @Produce json +// @Param ChangeMyPasswordDTO body ChangeMyPasswordDTO true "User's old & new passwords." +// @Success 204 {object} boolResponse +// @Failure 400 {object} PasswordValidation +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /my/password [post] +// @Security Bearer +// @Tags User Page +func (app *appContext) ChangeMyPassword(gc *gin.Context) { + var req ChangeMyPasswordDTO + gc.BindJSON(&req) + if req.Old == "" || req.New == "" { + respondBool(400, false, gc) + } + validation := app.validator.validate(req.New) + for _, val := range validation { + if !val { + app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId")) + gc.JSON(400, validation) + return + } + } + user, status, err := app.jf.UserByID(gc.GetString("jfId"), false) + if status != 200 || err != nil { + app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err) + respondBool(500, false, gc) + return + } + // Authenticate as user to confirm old password. + user, status, err = app.authJf.Authenticate(user.Name, req.Old) + if status != 200 || err != nil { + respondBool(401, false, gc) + return + } + status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New) + if (status != 200 && status != 204) || err != nil { + respondBool(500, false, gc) + return + } + if app.config.Section("ombi").Key("enabled").MustBool(false) { + ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId")) + if status != 200 || err != nil { + app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err) + respondBool(204, true, gc) + return + } + ombiUser["password"] = req.New + status, err = app.ombi.ModifyUser(ombiUser) + if status != 200 || err != nil { + app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err) + respondBool(204, true, gc) + return + } + app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) + } + respondBool(204, true, gc) +} diff --git a/api-users.go b/api-users.go index b73f591..18b4a10 100644 --- a/api-users.go +++ b/api-users.go @@ -463,6 +463,7 @@ func (app *appContext) NewUser(gc *gin.Context) { for _, val := range validation { if !val { valid = false + break } } if !valid { diff --git a/html/form.html b/html/form.html index 15e83e2..ae881b6 100644 --- a/html/form.html +++ b/html/form.html @@ -124,7 +124,7 @@
- {{ .strings.passwordRequirementsHeader }} + {{ .strings.passwordRequirementsHeader }}
    {{ range $key, $value := .requirements }}
  • diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 13ef671..5fd7b4c 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -50,6 +50,8 @@ "errorCaptcha": "Captcha incorrect.", "errorPassword": "Check password requirements.", "errorNoMatch": "Passwords don't match.", + "errorOldPassword": "Old password incorrect.", + "passwordChanged": "Password Changed.", "verified": "Account verified." }, "validationStrings": { diff --git a/models.go b/models.go index bc2204b..3f6502d 100644 --- a/models.go +++ b/models.go @@ -408,3 +408,8 @@ const ( type GetMyPINDTO struct { PIN string `json:"pin"` } + +type ChangeMyPasswordDTO struct { + Old string `json:"old"` + New string `json:"new"` +} diff --git a/router.go b/router.go index ef6254b..56754df 100644 --- a/router.go +++ b/router.go @@ -241,6 +241,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { user.DELETE(p+"/discord", app.UnlinkMyDiscord) user.DELETE(p+"/telegram", app.UnlinkMyTelegram) user.DELETE(p+"/matrix", app.UnlinkMyMatrix) + user.POST(p+"/password", app.ChangeMyPassword) } } } diff --git a/ts/form.ts b/ts/form.ts index 8707aa9..e0d3a57 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -2,7 +2,7 @@ import { Modal } from "./modules/modal.js"; import { notificationBox, whichAnimationEvent } from "./modules/common.js"; import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; -import { Validator, ValidatorConf } from "./modules/validator.js"; +import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; interface formWindow extends Window { @@ -257,11 +257,6 @@ if (window.emailRequired) { emailField.addEventListener("keyup", validator.validate) } -interface respDTO { - response: boolean; - error: string; -} - interface sendDTO { code: string; email: string; @@ -340,11 +335,11 @@ const create = (event: SubmitEvent) => { } _post("/newUser", send, (req: XMLHttpRequest) => { if (req.readyState == 4) { - let vals = req.response as respDTO; + let vals = req.response as ValidatorRespDTO; let valid = true; for (let type in vals) { - if (requirements[type]) { requirements[type].valid = vals[type]; } - if (!vals[type]) { valid = false; } + if (requirements[type]) requirements[type].valid = vals[type]; + if (!vals[type]) valid = false; } if (req.status == 200 && valid) { if (window.redirectToJellyfin == true) { diff --git a/ts/modules/validator.ts b/ts/modules/validator.ts index e535070..3a10843 100644 --- a/ts/modules/validator.ts +++ b/ts/modules/validator.ts @@ -9,6 +9,11 @@ interface pwValString { plural: string; } +export interface ValidatorRespDTO { + response: boolean; + error: string; +} + interface pwValStrings { length: pwValString; uppercase: pwValString; diff --git a/ts/user.ts b/ts/user.ts index a48c0f2..b21f555 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -1,10 +1,10 @@ import { ThemeManager } from "./modules/theme.js"; import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { Modal } from "./modules/modal.js"; -import { _get, _post, _delete, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; +import { _get, _post, _delete, notificationBox, whichAnimationEvent, toDateString, toggleLoader, addLoader, removeLoader } from "./modules/common.js"; import { Login } from "./modules/login.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; -import { Validator, ValidatorConf } from "./modules/validator.js"; +import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js"; interface userWindow extends Window { jellyfinID: string; @@ -385,7 +385,7 @@ const matrixConf: MatrixConfiguration = { }; let matrix = new Matrix(matrixConf); - + const oldPasswordField = document.getElementById("user-old-password") as HTMLInputElement; const newPasswordField = document.getElementById("user-new-password") as HTMLInputElement; @@ -405,9 +405,31 @@ let validatorConf: ValidatorConf = { }; let validator = new Validator(validatorConf); -let requirements = validator.requirements; +// let requirements = validator.requirements; oldPasswordField.addEventListener("keyup", validator.validate); +changePasswordButton.addEventListener("click", () => { + addLoader(changePasswordButton); + _post("/my/password", { old: oldPasswordField.value, new: newPasswordField.value }, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + removeLoader(changePasswordButton); + if (req.status == 400) { + window.notifications.customError("errorPassword", window.lang.notif("errorPassword")); + } else if (req.status == 500) { + window.notifications.customError("errorUnknown", window.lang.notif("errorUnknown")); + } else if (req.status == 204) { + window.notifications.customSuccess("passwordChanged", window.lang.notif("passwordChanged")); + setTimeout(() => { window.location.reload() }, 2000); + } + }, true, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status == 401) { + removeLoader(changePasswordButton); + window.notifications.customError("oldPasswordError", window.lang.notif("errorOldPassword")); + return; + } + }); +}); // FIXME: Submit & Validate document.addEventListener("details-reload", () => { From 6adbba54cebdfaedebe5376649febc3467029d8b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 20:58:56 +0100 Subject: [PATCH 52/54] userpage: invalid refresh token on pw change user has to log in again, although this is not strictly enforced, as the standard token remains valid until its expiry. --- api-userpage.go | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index 413f8cf..7996157 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -603,20 +603,27 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) { return } if app.config.Section("ombi").Key("enabled").MustBool(false) { - ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId")) - if status != 200 || err != nil { - app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err) - respondBool(204, true, gc) - return - } - ombiUser["password"] = req.New - status, err = app.ombi.ModifyUser(ombiUser) - if status != 200 || err != nil { - app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err) - respondBool(204, true, gc) - return - } - app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) + func() { + ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId")) + if status != 200 || err != nil { + app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err) + return + } + ombiUser["password"] = req.New + status, err = app.ombi.ModifyUser(ombiUser) + if status != 200 || err != nil { + app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err) + return + } + app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) + }() + } + cookie, err := gc.Cookie("user-refresh") + if err == nil { + app.invalidTokens = append(app.invalidTokens, cookie) + gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true) + } else { + app.debug.Printf("Couldn't get cookies: %s", err) } respondBool(204, true, gc) } From f07c60afb0799bf8550c9d268b08ba34bcd89de9 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 21:05:54 +0100 Subject: [PATCH 53/54] userpage: mention link reset requirement --- config/config-base.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config-base.json b/config/config-base.json index c8e08af..409cca7 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -394,7 +394,7 @@ "value": "", "depends_true": "enabled", "required": "false", - "description": "Jellyfin Login must be enabled to use this feature.", + "description": "Jellyfin Login must be enabled to use this feature, and password resets with a link must be enabled for self-service.", "style": "critical" }, "edit_note": { From 7aaafb90e34b62801b7807b75afdbb2d7bbfe63b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 21:57:19 +0100 Subject: [PATCH 54/54] form: actually link to the my account page forgot to do this before. shown on the success modal. --- config/config-base.json | 2 +- html/form-base.html | 1 + html/user.html | 2 +- ts/form.ts | 4 +++- views.go | 7 +++++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/config/config-base.json b/config/config-base.json index 409cca7..2a361c9 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -403,7 +403,7 @@ "value": "", "depends_true": "enabled", "required": "false", - "description": "Click the edit icon next to the \"User Page\" Setting to add custom Markdown messages that will be shown to the user." + "description": "Click the edit icon next to the \"User Page\" Setting to add custom Markdown messages that will be shown to the user. Note message cards are not private, little effort is required for anyone to view them." } } }, diff --git a/html/form-base.html b/html/form-base.html index 1675c56..ba8c318 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -30,6 +30,7 @@ window.reCAPTCHA = {{ .reCAPTCHA }}; window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}"; window.userPageEnabled = {{ .userPageEnabled }}; + window.userPageAddress = "{{ .userPageAddress }}"; {{ if .passwordReset }} diff --git a/html/user.html b/html/user.html index d75b1b9..e073cf2 100644 --- a/html/user.html +++ b/html/user.html @@ -26,7 +26,7 @@ window.validationStrings = JSON.parse({{ .validationStrings }}); {{ template "header.html" . }} - {{ .lang.Strings.myAccount }} + {{ .strings.myAccount }}