diff --git a/.gitignore b/.gitignore index 03f01d5..06a5ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ cl.md mautrix/ tempts/ matacc.txt +scripts/langmover/lang +scripts/langmover/lang2 +scripts/langmover/out 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/Makefile b/Makefile index bf9fb77..065c540 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,7 @@ typescript: $(info compiling typescript) mkdir -p $(DATA)/web/js $(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify + $(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify $(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify $(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify $(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify @@ -177,4 +178,6 @@ clean: -rm docs/docs.go docs/swagger.json docs/swagger.yaml go clean +quick: configuration typescript variants-html bundle-css inline-css copy compile + all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile diff --git a/api-invites.go b/api-invites.go index 1102297..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, @@ -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 { @@ -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 676ec15..c571eb7 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}) } @@ -285,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, @@ -305,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.telegram == nil { - app.storage.telegram = map[string]TelegramUser{} - } - app.storage.telegram[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] - } + app.storage.SetTelegramKey(req.ID, tgUser) linkExistingOmbiDiscordTelegram(app) respondBool(200, true, gc) } @@ -336,15 +367,14 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { respondBool(400, false, gc) return } - if tgUser, ok := app.storage.telegram[req.ID]; ok { + app.setContactMethods(req, gc) +} + +func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) { + 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 { @@ -353,10 +383,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { 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) @@ -370,10 +400,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { 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) @@ -387,10 +417,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { 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) @@ -416,19 +446,8 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { // @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. @@ -441,32 +460,18 @@ 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 } 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.telegram { - 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. @@ -479,20 +484,16 @@ 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 } 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 { - 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) } @@ -512,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 } @@ -536,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 } @@ -547,7 +548,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 @@ -574,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 @@ -644,8 +645,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 { @@ -653,13 +654,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) @@ -715,7 +716,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) @@ -739,8 +740,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) } @@ -758,8 +758,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) } @@ -777,7 +776,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 new file mode 100644 index 0000000..7996157 --- /dev/null +++ b/api-userpage.go @@ -0,0 +1,629 @@ +package main + +import ( + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" +) + +// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details. +// @Produce json +// @Success 200 {object} MyDetailsDTO +// @Router /my/details [get] +// @tags User Page +func (app *appContext) MyDetails(gc *gin.Context) { + resp := MyDetailsDTO{ + Id: gc.GetString("jfId"), + } + + user, status, err := app.jf.UserByID(resp.Id, false) + if status != 200 || err != nil { + app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err) + respond(500, "Failed to get user", gc) + return + } + resp.Username = user.Name + resp.Admin = user.Policy.IsAdministrator + resp.AccountsAdmin = false + if !app.config.Section("ui").Key("allow_all").MustBool(false) { + adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) + if emailStore, ok := app.storage.GetEmailsKey(resp.Id); ok { + resp.AccountsAdmin = emailStore.Admin + } + resp.AccountsAdmin = resp.AccountsAdmin || (adminOnly && resp.Admin) + } + resp.Disabled = user.Policy.IsDisabled + + if exp, ok := app.storage.users[user.ID]; ok { + resp.Expiry = exp.Unix() + } + + app.storage.loadEmails() + app.storage.loadDiscordUsers() + app.storage.loadMatrixUsers() + app.storage.loadTelegramUsers() + + if emailEnabled { + resp.Email = &MyDetailsContactMethodsDTO{} + if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" { + resp.Email.Value = email.Addr + resp.Email.Enabled = email.Contact + } + } + + if discordEnabled { + resp.Discord = &MyDetailsContactMethodsDTO{} + if discord, ok := app.storage.GetDiscordKey(user.ID); ok { + resp.Discord.Value = RenderDiscordUsername(discord) + resp.Discord.Enabled = discord.Contact + } + } + + if telegramEnabled { + resp.Telegram = &MyDetailsContactMethodsDTO{} + if telegram, ok := app.storage.GetTelegramKey(user.ID); ok { + resp.Telegram.Value = telegram.Username + resp.Telegram.Enabled = telegram.Contact + } + } + + if matrixEnabled { + resp.Matrix = &MyDetailsContactMethodsDTO{} + if matrix, ok := app.storage.GetMatrixKey(user.ID); ok { + resp.Matrix.Value = matrix.UserID + resp.Matrix.Enabled = matrix.Contact + } + } + + gc.JSON(200, resp) +} + +// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not. +// @Produce json +// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." +// @Success 200 {object} boolResponse +// @Success 400 {object} boolResponse +// @Success 500 {object} boolResponse +// @Router /my/contact [post] +// @Security Bearer +// @tags User Page +func (app *appContext) SetMyContactMethods(gc *gin.Context) { + var req SetContactMethodsDTO + gc.BindJSON(&req) + req.ID = gc.GetString("jfId") + if req.ID == "" { + respondBool(400, false, gc) + return + } + app.setContactMethods(req, gc) +} + +// @Summary Logout by deleting refresh token from cookies. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /my/logout [post] +// @Security Bearer +// @tags User Page +func (app *appContext) LogoutUser(gc *gin.Context) { + cookie, err := gc.Cookie("user-refresh") + if err != nil { + app.debug.Printf("Couldn't get cookies: %s", err) + respond(500, "Couldn't fetch cookies", gc) + return + } + app.invalidTokens = append(app.invalidTokens, cookie) + gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true) + respondBool(200, true, gc) +} + +// @Summary confirm an action (e.g. changing an email address.) +// @Produce json +// @Param jwt path string true "jwt confirmation code" +// @Router /my/confirm/{jwt} [post] +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 404 +// @Success 303 +// @Failure 500 {object} stringResponse +// @tags User Page +func (app *appContext) ConfirmMyAction(gc *gin.Context) { + app.confirmMyAction(gc, "") +} + +func (app *appContext) confirmMyAction(gc *gin.Context, key string) { + var claims jwt.MapClaims + var target ConfirmationTarget + var id string + fail := func() { + gcHTML(gc, 404, "404.html", gin.H{ + "cssClass": app.cssClass, + "cssVersion": cssVersion, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) + } + + // Validate key + if key == "" { + key = gc.Param("jwt") + } + token, err := jwt.Parse(key, checkToken) + if err != nil { + app.err.Printf("Failed to parse key: %s", err) + fail() + // respond(500, "unknownError", gc) + return + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + app.err.Printf("Failed to parse key: %s", err) + fail() + // respond(500, "unknownError", gc) + return + } + expiry := time.Unix(int64(claims["exp"].(float64)), 0) + if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) { + app.err.Printf("Invalid key") + fail() + // respond(400, "invalidKey", gc) + return + } + target = ConfirmationTarget(int(claims["target"].(float64))) + id = claims["id"].(string) + + // Perform an Action + if target == NoOp { + gc.Redirect(http.StatusSeeOther, "/my/account") + return + } else if target == UserEmailChange { + emailStore, ok := app.storage.GetEmailsKey(id) + if !ok { + emailStore = EmailAddress{ + Contact: true, + } + } + emailStore.Addr = claims["email"].(string) + 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 { + ombiUser["emailAddress"] = claims["email"].(string) + code, err = app.ombi.ModifyUser(ombiUser) + if code != 200 || err != nil { + app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err) + } + } + } + + app.storage.storeEmails() + app.info.Println("Email list modified") + gc.Redirect(http.StatusSeeOther, "/my/account") + return + } +} + +// @Summary Modify your email address. +// @Produce json +// @Param ModifyMyEmailDTO body ModifyMyEmailDTO true "New email address." +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 401 {object} stringResponse +// @Failure 500 {object} stringResponse +// @Router /my/email [post] +// @Security Bearer +// @tags User Page +func (app *appContext) ModifyMyEmail(gc *gin.Context) { + var req ModifyMyEmailDTO + gc.BindJSON(&req) + app.debug.Println("Email modification requested") + if !strings.ContainsRune(req.Email, '@') { + respond(400, "Invalid Email Address", gc) + return + } + id := gc.GetString("jfId") + + // We'll use the ConfirmMyAction route to do the work, even if we don't need to confirm the address. + claims := jwt.MapClaims{ + "valid": true, + "id": id, + "email": req.Email, + "type": "confirmation", + "target": UserEmailChange, + "exp": time.Now().Add(time.Hour).Unix(), + } + tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) + + if err != nil { + app.err.Printf("Failed to generate confirmation token: %v", err) + respond(500, "errorUnknown", gc) + return + } + + if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { + user, status, err := app.jf.UserByID(id, false) + name := "" + if status == 200 && err == nil { + name = user.Name + } + app.debug.Printf("%s: Email confirmation required", id) + respond(401, "confirmEmail", gc) + msg, err := app.email.constructConfirmation("", name, key, app, false) + if err != nil { + app.err.Printf("%s: Failed to construct confirmation email: %v", name, err) + } else if err := app.email.send(msg, req.Email); err != nil { + app.err.Printf("%s: Failed to send user confirmation email: %v", name, err) + } else { + app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email) + } + return + } + + 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] +// @Security Bearer +// @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] +// @Security Bearer +// @tags User Page +func (app *appContext) GetMyPIN(gc *gin.Context) { + service := gc.Param("service") + resp := GetMyPINDTO{} + switch service { + case "discord": + resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId")) + break + case "telegram": + resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId")) + 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] +// @Security Bearer +// @tags User Page +func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { + pin := gc.Param("pin") + dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId")) + app.discord.DeleteVerifiedUser(pin) + if !ok { + respondBool(200, 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 { + dcUser.Lang = existingUser.Lang + dcUser.Contact = existingUser.Contact + } + app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser) + 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] +// @Security Bearer +// @tags User Page +func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { + pin := gc.Param("pin") + token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId")) + app.telegram.DeleteVerifiedToken(pin) + if !ok { + respondBool(200, 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: 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 { + tgUser.Lang = existingUser.Lang + tgUser.Contact = existingUser.Contact + } + 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] +// @Security Bearer +// @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] +// @Security Bearer +// @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) +} + +// @Summary unlink the Discord account from your Jellyfin user. Always succeeds. +// @Produce json +// @Success 200 {object} boolResponse +// @Router /my/discord [delete] +// @Security Bearer +// @Tags User Page +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] +// @Security Bearer +// @Tags User Page +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] +// @Security Bearer +// @Tags User Page +func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { + app.storage.DeleteMatrixKey(gc.GetString("jfId")) + respondBool(200, true, gc) +} + +// @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 +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /my/password/reset/{address} [post] +// @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) + 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 + } + var pwr InternalPWR + var err error + + jfID := app.ReverseUserSearch(address) + if jfID == "" { + app.debug.Printf("Ignoring PWR request: User not found") + + 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) + for range timerWait { + respondBool(204, true, gc) + return + } + return + } + if app.internalPWRs == nil { + app.internalPWRs = map[string]InternalPWR{} + } + app.internalPWRs[pwr.PIN] = pwr + // FIXME: Send to all contact methods + msg, err := app.email.constructReset( + PasswordReset{ + Pin: pwr.PIN, + Username: pwr.Username, + Expiry: pwr.Expiry, + Internal: true, + }, app, false, + ) + if err != nil { + app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err) + 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) + } + for range timerWait { + respondBool(204, true, gc) + 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) { + 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) +} diff --git a/api-users.go b/api-users.go index 4452cac..18b4a10 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) { @@ -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) @@ -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) @@ -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,30 +215,22 @@ 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.telegram { - 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 } } } 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"))) @@ -254,10 +242,17 @@ 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() + if app.ConfirmationKeys == nil { + app.ConfirmationKeys = map[string]map[string]newUserDTO{} + } + 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) @@ -284,7 +279,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 { @@ -339,7 +334,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{} @@ -352,20 +347,19 @@ 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 = 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 { 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, @@ -375,15 +369,10 @@ 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[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] + app.storage.telegram = telegramStore{} } + app.telegram.DeleteVerifiedToken(req.TelegramPIN) + app.storage.SetTelegramKey(user.ID, tgUser) } if invite.Profile != "" && app.config.Section("ombi").Key("enabled").MustBool(false) { if profile.Ombi != nil && len(profile.Ombi) != 0 { @@ -394,18 +383,19 @@ 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 { - tUser = app.storage.telegram[user.ID].Username + if telegramVerified { + 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,14 +413,14 @@ 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) } } - 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) @@ -473,6 +463,7 @@ func (app *appContext) NewUser(gc *gin.Context) { for _, val := range validation { if !val { valid = false + break } } if !valid { @@ -488,7 +479,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 +888,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 +898,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 +940,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 +977,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 +1015,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 +1026,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/api.go b/api.go index 7fc4740..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 { @@ -214,7 +215,7 @@ func (app *appContext) GetConfig(gc *gin.Context) { app.info.Println("Config requested") resp := app.configBase // Load language options - formOptions := app.storage.lang.Form.getOptions() + formOptions := app.storage.lang.User.getOptions() fl := resp.Sections["ui"].Settings["language-form"] fl.Options = formOptions fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us") @@ -268,7 +269,7 @@ func (app *appContext) GetConfig(gc *gin.Context) { val := app.config.Section(sectName).Key(settingName) s := resp.Sections[sectName].Settings[settingName] switch setting.Type { - case "text", "email", "select", "password": + case "text", "email", "select", "password", "note": s.Value = val.MustString("") case "number": s.Value = val.MustInt(0) @@ -452,8 +453,8 @@ func (app *appContext) GetLanguages(gc *gin.Context) { page := gc.Param("page") resp := langDTO{} switch page { - case "form": - for key, lang := range app.storage.lang.Form { + case "form", "user": + for key, lang := range app.storage.lang.User { resp[key] = lang.Meta.Name } case "admin": @@ -494,8 +495,8 @@ func (app *appContext) ServeLang(gc *gin.Context) { if page == "admin" { gc.JSON(200, app.storage.lang.Admin[lang]) return - } else if page == "form" { - gc.JSON(200, app.storage.lang.Form[lang]) + } else if page == "form" || page == "user" { + gc.JSON(200, app.storage.lang.User[lang]) return } respondBool(400, false, gc) diff --git a/auth.go b/auth.go index 94344f6..6ce4926 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,23 +63,48 @@ 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 + } 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 + 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 +117,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 +133,44 @@ 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 + } + if status == 403 { + app.info.Println("Auth denied: Jellyfin account disabled") + respond(403, "yourAccountWasDisabled", 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 +181,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,27 +201,20 @@ 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 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) 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 +224,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,15 +237,9 @@ 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)") - cookie, err := gc.Cookie("refresh") +func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName string) (claims jwt.MapClaims, ok bool) { + ok = false + cookie, err := gc.Cookie(cookieName) if err != nil || cookie == "" { app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err) respond(400, "Couldn't get token", gc) @@ -203,27 +258,45 @@ 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) + ok = false return } 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 + 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, "refresh") + 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/config.go b/config.go index 3a0748c..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 != "" { @@ -169,11 +175,11 @@ func (app *appContext) loadConfig() error { oldFormLang := app.config.Section("ui").Key("language").MustString("") if oldFormLang != "" { - app.storage.lang.chosenFormLang = oldFormLang + app.storage.lang.chosenUserLang = oldFormLang } newFormLang := app.config.Section("ui").Key("language-form").MustString("") if newFormLang != "" { - app.storage.lang.chosenFormLang = newFormLang + app.storage.lang.chosenUserLang = newFormLang } app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us") app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us") diff --git a/config/config-base.json b/config/config-base.json index f8ed5ee..2a361c9 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -373,6 +373,40 @@ } } }, + "user_page": { + "order": [], + "meta": { + "name": "User Page", + "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": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": false, + "type": "bool", + "value": true + }, + "jellyfin_login_note": { + "name": "Note:", + "type": "note", + "value": "", + "depends_true": "enabled", + "required": "false", + "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": { + "name": "Message Cards:", + "type": "note", + "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. Note message cards are not private, little effort is required for anyone to view them." + } + } + }, "password_validation": { "order": [], "meta": { @@ -465,6 +499,14 @@ "type": "text", "value": "Need help? contact me.", "description": "Message displayed at bottom of emails." + }, + "edit_note": { + "name": "Customize Messages:", + "type": "note", + "value": "", + "depends_true": "enabled", + "required": "false", + "description": "Click the edit icon next to the \"Messages/Notifications\" Setting to customize the messages sent to users with Markdown." } } }, @@ -1534,6 +1576,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..3376742 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 { @@ -485,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/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 1f37a03..607696c 100644 --- a/discord.go +++ b/discord.go @@ -3,6 +3,7 @@ package main import ( "fmt" "strings" + "time" dg "github.com/bwmarrin/discordgo" ) @@ -12,8 +13,8 @@ type DiscordDaemon struct { ShutdownChannel chan string bot *dg.Session username string - tokens []string - 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 @@ -37,7 +38,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { Stopped: false, ShutdownChannel: make(chan string), bot: bot, - tokens: []string{}, + tokens: map[string]VerifToken{}, verifiedTokens: map[string]DiscordUser{}, users: map[string]DiscordUser{}, app: app, @@ -48,7 +49,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 } @@ -58,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 = append(d.tokens, pin) + 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 } @@ -210,7 +219,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 } @@ -429,14 +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() - tokenIndex := -1 - for i, token := range d.tokens { - if pin == token { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + 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, @@ -448,6 +453,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{ @@ -461,23 +467,21 @@ 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] - 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] + dcUser := d.users[i.Interaction.Member.User.ID] + dcUser.JellyfinID = user.JellyfinID + d.verifiedTokens[pin] = dcUser + delete(d.tokens, pin) } func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang string) { 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 } @@ -580,13 +584,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 } @@ -610,14 +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 } - tokenIndex := -1 - for i, token := range d.tokens { - if sects[0] == token { - tokenIndex = i - break - } - } - if tokenIndex == -1 { + 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"), @@ -625,6 +620,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( @@ -634,9 +630,10 @@ 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] - 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] + dcUser := d.users[m.Author.ID] + dcUser.JellyfinID = user.JellyfinID + d.verifiedTokens[sects[0]] = dcUser + delete(d.tokens, sects[0]) } func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error { @@ -690,3 +687,38 @@ 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 +} + +// 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 + 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/email.go b/email.go index 03a30b2..4ee8f36 100644 --- a/email.go +++ b/email.go @@ -10,6 +10,7 @@ import ( "html/template" "io" "io/fs" + "net/url" "os" "strconv" "strings" @@ -23,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 { @@ -304,10 +305,17 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC } else { message := app.config.Section("messages").Key("message").String() inviteLink := app.config.Section("invite_emails").Key("url_base").String() - if !strings.HasSuffix(inviteLink, "/invite") { - inviteLink += "/invite" + if code == "" { // Personal email change + if strings.HasSuffix(inviteLink, "/invite") { + inviteLink = strings.TrimSuffix(inviteLink, "/invite") + } + inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key)) + } else { // Invite email confirmation + if !strings.HasSuffix(inviteLink, "/invite") { + inviteLink += "/invite" + } + inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, url.PathEscape(key)) } - inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key) template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) template["confirmationURL"] = inviteLink template["message"] = message @@ -345,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 @@ -514,18 +522,6 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite return email, 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 (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} { d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) message := app.config.Section("messages").Key("message").String() @@ -827,49 +823,82 @@ func (emailer *Emailer) send(email *Message, address ...string) error { return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...) } -func (app *appContext) sendByID(email *Message, ID ...string) error { +func (app *appContext) sendByID(email *Message, ID ...string) (err 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 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 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 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 - } - } - if err != nil { - return err + // if err != nil { + // return err + // } } + // if err != nil { + // return err + // } } - return nil + return } 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 } + if mxChat, ok := app.storage.GetMatrixKey(jfID); ok && mxChat.Contact && matrixEnabled { + return mxChat.UserID + } + return "" +} + +// 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 + } + } + for id, dcUser := range app.storage.GetDiscord() { + if RenderDiscordUsername(dcUser) == strings.ToLower(address) { + return id + } + } + tgUsername := strings.TrimPrefix(address, "@") + for id, tgUser := range app.storage.GetTelegram() { + if tgUsername == tgUser.Username { + return id + } + } + for id, mxUser := range app.storage.GetMatrix() { + if address == mxUser.UserID { + return id + } + } return "" } diff --git a/html/account-linking.html b/html/account-linking.html new file mode 100644 index 0000000..e38f013 --- /dev/null +++ b/html/account-linking.html @@ -0,0 +1,52 @@ +{{ if .discordEnabled }} +{{ .discordSendPINMessage }}
+ + + +{{ .strings.sendPIN }}
+ + + + + + + + @{{ .telegramUsername }} + + +{{ .strings.matrixEnterUser }}
+ +{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}
+ {{ if .userPageEnabled }}{{ .strings.userPageSuccessMessage }}
{{ end }} {{ .strings.continue }}{{ .strings.confirmationRequiredMessage }}
{{ .strings.sendPIN }}
-{{ .telegramPIN }}
- - - - - - - @{{ .telegramUsername }} - - -{{ .strings.matrixEnterUser }}
- -{{ .strings.confirmationRequiredMessage }}
++ {{ if .linkResetEnabled }} + {{ .strings.resetPasswordThroughLink }} + {{ else }} + {{ .strings.resetPasswordThroughJellyfin }} + {{ end }} +
+ تنظیم کنید."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/fr-fr.json b/lang/telegram/fr-fr.json
index b1a9e3f..648e3bb 100644
--- a/lang/telegram/fr-fr.json
+++ b/lang/telegram/fr-fr.json
@@ -13,4 +13,4 @@
"languageSet": "Langue définie sur {language}.",
"languageMessageDiscord": "Note : définissez votre langue avec /lang ."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/it-IT.json b/lang/telegram/it-it.json
similarity index 99%
rename from lang/telegram/it-IT.json
rename to lang/telegram/it-it.json
index f26fe4e..cbd945b 100644
--- a/lang/telegram/it-IT.json
+++ b/lang/telegram/it-it.json
@@ -13,4 +13,4 @@
"languageSet": "",
"discordDMs": ""
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/nl-nl.json b/lang/telegram/nl-nl.json
index ef7fb21..a94316f 100644
--- a/lang/telegram/nl-nl.json
+++ b/lang/telegram/nl-nl.json
@@ -13,4 +13,4 @@
"languageSet": "Taal ingesteld als {language}.",
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/pl-PL.json b/lang/telegram/pl-pl.json
similarity index 99%
rename from lang/telegram/pl-PL.json
rename to lang/telegram/pl-pl.json
index b18b204..2e1a212 100644
--- a/lang/telegram/pl-PL.json
+++ b/lang/telegram/pl-pl.json
@@ -13,4 +13,4 @@
"languageSet": "Język ustawiony jako {language}.",
"discordDMs": "Sprawdź swoje wiadomości, aby uzyskać odpowiedź."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/pt-br.json b/lang/telegram/pt-br.json
index 900d8d2..763234f 100644
--- a/lang/telegram/pt-br.json
+++ b/lang/telegram/pt-br.json
@@ -13,4 +13,4 @@
"discordDMs": "Por favor, verifique seus DMs para uma resposta.",
"discordStartMessage": "Oi!\n Digite seu PIN com `/pin ` para verificar sua conta."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/ro-RO.json b/lang/telegram/ro-ro.json
similarity index 99%
rename from lang/telegram/ro-RO.json
rename to lang/telegram/ro-ro.json
index 5825ba5..3ab408e 100644
--- a/lang/telegram/ro-RO.json
+++ b/lang/telegram/ro-ro.json
@@ -13,4 +13,4 @@
"languageSet": "Limba setată la {language}.",
"discordDMs": "Vă rugăm să verificați DM-urile pentru un răspuns."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/sl-si.json b/lang/telegram/sl-si.json
index 2291294..09ec047 100644
--- a/lang/telegram/sl-si.json
+++ b/lang/telegram/sl-si.json
@@ -13,4 +13,4 @@
"languageSet": "Jezik nastavljen na {language}.",
"discordDMs": "Prosimo preverite svoja zasebna sporočila za odziv."
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/zh-hans.json b/lang/telegram/zh-hans.json
index c7159b1..84f3ab8 100644
--- a/lang/telegram/zh-hans.json
+++ b/lang/telegram/zh-hans.json
@@ -13,4 +13,4 @@
"languageSet": "语言改成 {language}。",
"discordDMs": "请检查您的DM找回答。"
}
-}
+}
\ No newline at end of file
diff --git a/lang/telegram/zh-Hant.json b/lang/telegram/zh-hant.json
similarity index 99%
rename from lang/telegram/zh-Hant.json
rename to lang/telegram/zh-hant.json
index 5b730f2..7cc97f2 100644
--- a/lang/telegram/zh-Hant.json
+++ b/lang/telegram/zh-hant.json
@@ -13,4 +13,4 @@
"languageSet": "語言設置為{Language}。",
"discordDMs": "請檢查您的私人通信以獲取回復。"
}
-}
+}
\ No newline at end of file
diff --git a/main.go b/main.go
index fb6c725..79643cd 100644
--- a/main.go
+++ b/main.go
@@ -17,6 +17,7 @@ import (
"path/filepath"
"runtime"
"strings"
+ "sync"
"syscall"
"time"
@@ -87,30 +88,32 @@ 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
- 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) {
@@ -284,7 +287,7 @@ func start(asDaemon, firstCall bool) {
}
app.storage.lang.CommonPath = "common"
- app.storage.lang.FormPath = "form"
+ app.storage.lang.UserPath = "form"
app.storage.lang.AdminPath = "admin"
app.storage.lang.EmailPath = "email"
app.storage.lang.TelegramPath = "telegram"
@@ -450,7 +453,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,9 +648,15 @@ 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.-
+// @tag.name User Page
+// @tag.description User-page related routes.
+
// @tag.name Users
// @tag.description Jellyfin user related operations.
diff --git a/matrix.go b/matrix.go
index ecd8268..9935a2c 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)
}
}
@@ -252,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/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/models.go b/models.go
index d553268..3f6502d 100644
--- a/models.go
+++ b/models.go
@@ -215,6 +215,7 @@ type setting struct {
Options [][2]string `json:"options,omitempty"`
DependsTrue string `json:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
DependsFalse string `json:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
+ Style string `json:"style,omitempty"`
}
type section struct {
@@ -373,3 +374,42 @@ type ReCaptchaResponseDTO struct {
Hostname string `json:"hostname"`
ErrorCodes []string `json:"error-codes"`
}
+
+// MyDetailsDTO is sent to the user page to personalize it for the user.
+type MyDetailsDTO struct {
+ Id string `json:"id"`
+ Username string `json:"username"`
+ Expiry int64 `json:"expiry"`
+ Admin bool `json:"admin"`
+ AccountsAdmin bool `json:"accounts_admin"`
+ Disabled bool `json:"disabled"`
+ Email *MyDetailsContactMethodsDTO `json:"email,omitempty"`
+ Discord *MyDetailsContactMethodsDTO `json:"discord,omitempty"`
+ Telegram *MyDetailsContactMethodsDTO `json:"telegram,omitempty"`
+ Matrix *MyDetailsContactMethodsDTO `json:"matrix,omitempty"`
+}
+
+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
+)
+
+type GetMyPINDTO struct {
+ PIN string `json:"pin"`
+}
+
+type ChangeMyPasswordDTO struct {
+ Old string `json:"old"`
+ New string `json:"new"`
+}
diff --git a/package-lock.json b/package-lock.json
index 173cc4f..c1cd61f 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.3",
+ "esbuild": "^0.18.6",
"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.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.3.tgz",
- "integrity": "sha512-QOn3VIlL6Qv1eHBpQB/s7simaZgGss2ASyxDOwYSLmc6vD0uuizZkuYawHmuLjWEm5wPwp0JQWhbpaYwwGevYw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.6.tgz",
+ "integrity": "sha512-J3lwhDSXBBppSzm/LC1uZ8yKSIpExc+5T8MxrYD9KNVZG81FOAu2VF2gXi/6A/LwDDQQ+b6DpQbYlo3VwxFepQ==",
"cpu": [
"arm"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.3.tgz",
- "integrity": "sha512-PgabCsoaEEnnOiF6rUhOBXgYoLFIrHWP6mfLOzuQ1oZ1lwBdTL0hp5ivC4K3Kvz3BD8EipjeQo6l0aty3nr4qQ==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.6.tgz",
+ "integrity": "sha512-pL0Ci8P9q1sWbtPx8CXbc8JvPvvYdJJQ+LO09PLFsbz3aYNdFBGWJjiHU+CaObO4Ames+GOFpXRAJZS2L3ZK/A==",
"cpu": [
"arm64"
],
@@ -87,9 +87,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.3.tgz",
- "integrity": "sha512-1OkJf8wNX1W5ucbp5HrK+z42b9DINb4ix59oJH/PIsh9cyFMqjgRKtCBXg0zEWhkmP1k3egdfrnS7cDTpLH43g==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.6.tgz",
+ "integrity": "sha512-hE2vZxOlJ05aY28lUpB0y0RokngtZtcUB+TVl9vnLEnY0z/8BicSvrkThg5/iI1rbf8TwXrbr2heEjl9fLf+EA==",
"cpu": [
"x64"
],
@@ -102,9 +102,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.3.tgz",
- "integrity": "sha512-57aofORpY7wDAuMs6DeqpmgSnVfZ63RgGbR/BHdOSTqJgYvHDCMY7/o1myFntl3k0YxtLE3WAm56nMf4qy3UDw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.6.tgz",
+ "integrity": "sha512-/tuyl4R+QhhoROQtuQj9E/yfJtZNdv2HKaHwYhhHGQDN1Teziem2Kh7BWQMumfiY7Lu9g5rO7scWdGE4OsQ6MQ==",
"cpu": [
"arm64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.3.tgz",
- "integrity": "sha512-NVBqMnxT9qvgu7Z322LUDlwjh4GDk6wEePyAQnHF9noxik/WvLFmr5v3Vgz5LSvqFducLCxsdmLztKhdpFW0Gg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.6.tgz",
+ "integrity": "sha512-L7IQga2pDT+14Ti8HZwsVfbCjuKP4U213T3tuPggOzyK/p4KaUJxQFXJgfUFHKzU0zOXx8QcYRYZf0hSQtppkw==",
"cpu": [
"x64"
],
@@ -132,9 +132,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.3.tgz",
- "integrity": "sha512-XiLK1AsCk2wKxN7j8h9GXXCs8FPZhp07U0rnpwRkAVSVGgLaIWYSqpTRzKjAfqJiZlp+XKo1HwsmDdICEKB3Dg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.6.tgz",
+ "integrity": "sha512-bq10jFv42V20Kk77NvmO+WEZaLHBKuXcvEowixnBOMkaBgS7kQaqTc77ZJDbsUpXU3KKNLQFZctfaeINmeTsZA==",
"cpu": [
"arm64"
],
@@ -147,9 +147,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.3.tgz",
- "integrity": "sha512-xyITfrF0G3l1gwR79hvNCCWKQ/16uK14xNNPFgzjbIqF4EpBvhO6l3jrWxXFUW51z6dVIl2Szh3x3uIbBWzH1Q==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.6.tgz",
+ "integrity": "sha512-HbDLlkDZqUMBQaiday0pJzB6/8Xx/10dI3xRebJBReOEeDSeS+7GzTtW9h8ZnfB7/wBCqvtAjGtWQLTNPbR2+g==",
"cpu": [
"x64"
],
@@ -162,9 +162,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.3.tgz",
- "integrity": "sha512-fc/T0QHMzvmnlF+kfD6bHLB8u+17gg13260p/E86yYjVoKNFjonL/+Y0GGQjMbFUas9QijqOa7pcR00a9RNkwg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.6.tgz",
+ "integrity": "sha512-C+5kb6rgsGMmvIdUI7v1PPgC98A6BMv233e97aXZ5AE03iMdlILFD/20HlHrOi0x2CzbspXn9HOnlE4/Ijn5Kw==",
"cpu": [
"arm"
],
@@ -177,9 +177,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.3.tgz",
- "integrity": "sha512-lsKUYVd8L/j2uNs8dhMjMsKC5MHYh77gR9EThu7YCeeFz1XpIkx1I4a7mhoVfPS2VPVD1pMCh+PgxuAHUcEmXw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.6.tgz",
+ "integrity": "sha512-NMY9yg/88MskEZH2s4i6biz/3av+M8xY5ua4HE7CCz5DBz542cr7REe317+v7oKjnYBCijHpkzo5vU85bkXQmQ==",
"cpu": [
"arm64"
],
@@ -192,9 +192,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.3.tgz",
- "integrity": "sha512-EyfGWeOwRqK5Xj18vok0qv8IFBZ1/+hKV+cqD44oVhGsxHo9TmPtoSiDrWn8Sa2swq/VuO5Aiog6YPDj81oIkA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.6.tgz",
+ "integrity": "sha512-AXazA0ljvQEp7cA9jscABNXsjodKbEcqPcAE3rDzKN82Vb3lYOq6INd+HOCA7hk8IegEyHW4T72Z7QGIhyCQEA==",
"cpu": [
"ia32"
],
@@ -207,9 +207,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.3.tgz",
- "integrity": "sha512-PwXkcl3t0kSeYH5RuJIeh/fHOzKZd+ZdifAWzpVO+9TLWArutTFBJvOSkTZ3CcqQqNrTj1Qyo6nqE8MQj/a7cQ==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.6.tgz",
+ "integrity": "sha512-JjBf7TwY7ldcPgHYt9UcrjZB03+WZqg/jSwMAfzOzM5ZG+tu5umUqzy5ugH/crGI4eoDIhSOTDp1NL3Uo/05Fw==",
"cpu": [
"loong64"
],
@@ -222,9 +222,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.3.tgz",
- "integrity": "sha512-CRVkkSXf5GQcq7Am2a2tdIn85oqi/bkjuPvhNqcdeTgI0xgNbqLnEPRy2AEGkRuaJWB5uCX1IC4sqnY8ET14Yg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.6.tgz",
+ "integrity": "sha512-kATNsslryVxcH1sO3KP2nnyUWtZZVkgyhAUnyTVVa0OQQ9pmDRjTpHaE+2EQHoCM5wt/uav2edrAUqbwn3tkKQ==",
"cpu": [
"mips64el"
],
@@ -237,9 +237,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.3.tgz",
- "integrity": "sha512-t7zK1Cheh0xvzfZbimztiE0wGnpV+YRsBg3tefcEBN3O4GzgLu6fFpA5HxEyVm3hHZW1jAC4OhoGEp7C5Ii6Eg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.6.tgz",
+ "integrity": "sha512-B+wTKz+8pi7mcWXFQV0LA79dJ+qhiut5uK9q0omoKnq8yRIwQJwfg3/vclXoqqcX89Ri5Y5538V0Se2v5qlcLA==",
"cpu": [
"ppc64"
],
@@ -252,9 +252,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.3.tgz",
- "integrity": "sha512-fUZPtyCYih6y4lDYdSM4Yoax4nS7aH0/XixJStys+9tfp5cAlIAZhEVKOOdeGXmQn0IEyiUtlIsPnfObbeDQfQ==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.6.tgz",
+ "integrity": "sha512-h44RBLVXFUSjvhOfseE+5UxQ/r9LVeqK2S8JziJKOm9W7SePYRPDyn7MhzhNCCFPkcjIy+soCxfhlJXHXXCR0A==",
"cpu": [
"riscv64"
],
@@ -267,9 +267,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.3.tgz",
- "integrity": "sha512-oIcK2LqHWqfMERqjvaKJ3QJmycHn723HsXIv5gH4iGfmePfSj+gi0ZQv2h4bHUg2bs2gJtV0DlIjGhEuvdgxLw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.6.tgz",
+ "integrity": "sha512-FlYpyr2Xc2AUePoAbc84NRV+mj7xpsISeQ36HGf9etrY5rTBEA+IU9HzWVmw5mDFtC62EQxzkLRj8h5Hq85yOQ==",
"cpu": [
"s390x"
],
@@ -282,9 +282,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.3.tgz",
- "integrity": "sha512-RW9lpfZ6XZ6f5to2DJPvt0f/4RXEW229Xf++quVoW+YbnPrcapIJChtD/AmZ8cK3hglO/hXxJjs21pV0/l7L5w==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.6.tgz",
+ "integrity": "sha512-Mc4EUSYwzLci77u0Kao6ajB2WbTe5fNc7+lHwS3a+vJISC/oprwURezUYu1SdWAYoczbsyOvKAJwuNftoAdjjg==",
"cpu": [
"x64"
],
@@ -297,9 +297,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.3.tgz",
- "integrity": "sha512-piZ2oBoaq58pKZvhgdV6PemlL30Uhd9GmmOkIGZYgChwNcyVSSl6iMEJxMzU7x44Lk9q+hJ6a343M/iVEMEvxA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.6.tgz",
+ "integrity": "sha512-3hgZlp7NqIM5lNG3fpdhBI5rUnPmdahraSmwAi+YX/bp7iZ7mpTv2NkypGs/XngdMtpzljICxnUG3uPfqLFd3w==",
"cpu": [
"x64"
],
@@ -312,9 +312,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.3.tgz",
- "integrity": "sha512-vaMfouYTz/4tKdQsXDccqhV6wgPEr+hfuxdNU5Pl/vQxYTsqcXv5DYEa5Z1RAxCoua5aEB+Uj5V7VT/bM92wxw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.6.tgz",
+ "integrity": "sha512-aEWTdZQHtSRROlDYn7ygB8yAqtnall/UnmoVIJVqccKitkAWVVSYocQUWrBOxLEFk8XdlRouVrLZe6WXszyviA==",
"cpu": [
"x64"
],
@@ -327,9 +327,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.3.tgz",
- "integrity": "sha512-Fa3rNQQ9q1qwy9u2cdDvuGKy3jmPnPPMDdyy/qbn5d395Pb9hjLYiPzX9BozXMPJDlCNofSY7jN3miM9gyAdHA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.6.tgz",
+ "integrity": "sha512-uxk/5yAGpjKZUHOECtI9W+9IcLjKj+2m0qf+RG7f7eRBHr8wP6wsr3XbNbgtOD1qSpPapd6R2ZfSeXTkCcAo5g==",
"cpu": [
"x64"
],
@@ -342,9 +342,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.3.tgz",
- "integrity": "sha512-LPJC8ub+9uzyC6ygVmp00dAqet1q1DsZ/OldGIIBt+y+Ctd1OfnKNlzQgXK8nxwY1G8fAhklFSeSRRgAUJnR0w==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.6.tgz",
+ "integrity": "sha512-oXlXGS9zvNCGoAT/tLHAsFKrIKye1JaIIP0anCdpaI+Dc10ftaNZcqfLzEwyhdzFAYInXYH4V7kEdH4hPyo9GA==",
"cpu": [
"arm64"
],
@@ -357,9 +357,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.3.tgz",
- "integrity": "sha512-WtUyRspyxZR6NTc2HG4xd9Wvz8lP4C6OUY1gAqisrf151HvXIxsK0mfAacFJNS7EN2wvPTgjP+SM8vgBOx5+zA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.6.tgz",
+ "integrity": "sha512-qh7IcAHUvvmMBmoIG+V+BbE9ZWSR0ohF51e5g8JZvU08kZF58uDFL5tHs0eoYz31H6Finv17te3W3QB042GqVA==",
"cpu": [
"ia32"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.3.tgz",
- "integrity": "sha512-Z8qCK4BkBm40j5KUM4NrkxYQS0R12cBO1NBVtI4vws6uwh1n/VaNu31Hm+n2cJUWdFbfH57PBghkhm9yLgmPfw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.6.tgz",
+ "integrity": "sha512-9UDwkz7Wlm4N9jnv+4NL7F8vxLhSZfEkRArz2gD33HesAFfMLGIGNVXRoIHtWNw8feKsnGly9Hq1EUuRkWl0zA==",
"cpu": [
"x64"
],
@@ -1647,9 +1647,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.3.tgz",
- "integrity": "sha512-eadWJC4CRpj93+miO5ZBlvCv+m2x6pzyNBznTvUeLFObMmxs1IMd8cCf6qiDVEZuDL6W8W7u+ZNW3GKEfOdDsA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.6.tgz",
+ "integrity": "sha512-5QgxWaAhU/tPBpvkxUmnFv2YINHuZzjbk0LeUUnC2i3aJHjfi5yR49lgKgF7cb98bclOp/kans8M5TGbGFfJlQ==",
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@@ -1658,28 +1658,28 @@
"node": ">=12"
},
"optionalDependencies": {
- "@esbuild/android-arm": "0.18.3",
- "@esbuild/android-arm64": "0.18.3",
- "@esbuild/android-x64": "0.18.3",
- "@esbuild/darwin-arm64": "0.18.3",
- "@esbuild/darwin-x64": "0.18.3",
- "@esbuild/freebsd-arm64": "0.18.3",
- "@esbuild/freebsd-x64": "0.18.3",
- "@esbuild/linux-arm": "0.18.3",
- "@esbuild/linux-arm64": "0.18.3",
- "@esbuild/linux-ia32": "0.18.3",
- "@esbuild/linux-loong64": "0.18.3",
- "@esbuild/linux-mips64el": "0.18.3",
- "@esbuild/linux-ppc64": "0.18.3",
- "@esbuild/linux-riscv64": "0.18.3",
- "@esbuild/linux-s390x": "0.18.3",
- "@esbuild/linux-x64": "0.18.3",
- "@esbuild/netbsd-x64": "0.18.3",
- "@esbuild/openbsd-x64": "0.18.3",
- "@esbuild/sunos-x64": "0.18.3",
- "@esbuild/win32-arm64": "0.18.3",
- "@esbuild/win32-ia32": "0.18.3",
- "@esbuild/win32-x64": "0.18.3"
+ "@esbuild/android-arm": "0.18.6",
+ "@esbuild/android-arm64": "0.18.6",
+ "@esbuild/android-x64": "0.18.6",
+ "@esbuild/darwin-arm64": "0.18.6",
+ "@esbuild/darwin-x64": "0.18.6",
+ "@esbuild/freebsd-arm64": "0.18.6",
+ "@esbuild/freebsd-x64": "0.18.6",
+ "@esbuild/linux-arm": "0.18.6",
+ "@esbuild/linux-arm64": "0.18.6",
+ "@esbuild/linux-ia32": "0.18.6",
+ "@esbuild/linux-loong64": "0.18.6",
+ "@esbuild/linux-mips64el": "0.18.6",
+ "@esbuild/linux-ppc64": "0.18.6",
+ "@esbuild/linux-riscv64": "0.18.6",
+ "@esbuild/linux-s390x": "0.18.6",
+ "@esbuild/linux-x64": "0.18.6",
+ "@esbuild/netbsd-x64": "0.18.6",
+ "@esbuild/openbsd-x64": "0.18.6",
+ "@esbuild/sunos-x64": "0.18.6",
+ "@esbuild/win32-arm64": "0.18.6",
+ "@esbuild/win32-ia32": "0.18.6",
+ "@esbuild/win32-x64": "0.18.6"
}
},
"node_modules/escalade": {
@@ -6797,135 +6797,135 @@
}
},
"@esbuild/android-arm": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.3.tgz",
- "integrity": "sha512-QOn3VIlL6Qv1eHBpQB/s7simaZgGss2ASyxDOwYSLmc6vD0uuizZkuYawHmuLjWEm5wPwp0JQWhbpaYwwGevYw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.6.tgz",
+ "integrity": "sha512-J3lwhDSXBBppSzm/LC1uZ8yKSIpExc+5T8MxrYD9KNVZG81FOAu2VF2gXi/6A/LwDDQQ+b6DpQbYlo3VwxFepQ==",
"optional": true
},
"@esbuild/android-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.3.tgz",
- "integrity": "sha512-PgabCsoaEEnnOiF6rUhOBXgYoLFIrHWP6mfLOzuQ1oZ1lwBdTL0hp5ivC4K3Kvz3BD8EipjeQo6l0aty3nr4qQ==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.6.tgz",
+ "integrity": "sha512-pL0Ci8P9q1sWbtPx8CXbc8JvPvvYdJJQ+LO09PLFsbz3aYNdFBGWJjiHU+CaObO4Ames+GOFpXRAJZS2L3ZK/A==",
"optional": true
},
"@esbuild/android-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.3.tgz",
- "integrity": "sha512-1OkJf8wNX1W5ucbp5HrK+z42b9DINb4ix59oJH/PIsh9cyFMqjgRKtCBXg0zEWhkmP1k3egdfrnS7cDTpLH43g==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.6.tgz",
+ "integrity": "sha512-hE2vZxOlJ05aY28lUpB0y0RokngtZtcUB+TVl9vnLEnY0z/8BicSvrkThg5/iI1rbf8TwXrbr2heEjl9fLf+EA==",
"optional": true
},
"@esbuild/darwin-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.3.tgz",
- "integrity": "sha512-57aofORpY7wDAuMs6DeqpmgSnVfZ63RgGbR/BHdOSTqJgYvHDCMY7/o1myFntl3k0YxtLE3WAm56nMf4qy3UDw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.6.tgz",
+ "integrity": "sha512-/tuyl4R+QhhoROQtuQj9E/yfJtZNdv2HKaHwYhhHGQDN1Teziem2Kh7BWQMumfiY7Lu9g5rO7scWdGE4OsQ6MQ==",
"optional": true
},
"@esbuild/darwin-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.3.tgz",
- "integrity": "sha512-NVBqMnxT9qvgu7Z322LUDlwjh4GDk6wEePyAQnHF9noxik/WvLFmr5v3Vgz5LSvqFducLCxsdmLztKhdpFW0Gg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.6.tgz",
+ "integrity": "sha512-L7IQga2pDT+14Ti8HZwsVfbCjuKP4U213T3tuPggOzyK/p4KaUJxQFXJgfUFHKzU0zOXx8QcYRYZf0hSQtppkw==",
"optional": true
},
"@esbuild/freebsd-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.3.tgz",
- "integrity": "sha512-XiLK1AsCk2wKxN7j8h9GXXCs8FPZhp07U0rnpwRkAVSVGgLaIWYSqpTRzKjAfqJiZlp+XKo1HwsmDdICEKB3Dg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.6.tgz",
+ "integrity": "sha512-bq10jFv42V20Kk77NvmO+WEZaLHBKuXcvEowixnBOMkaBgS7kQaqTc77ZJDbsUpXU3KKNLQFZctfaeINmeTsZA==",
"optional": true
},
"@esbuild/freebsd-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.3.tgz",
- "integrity": "sha512-xyITfrF0G3l1gwR79hvNCCWKQ/16uK14xNNPFgzjbIqF4EpBvhO6l3jrWxXFUW51z6dVIl2Szh3x3uIbBWzH1Q==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.6.tgz",
+ "integrity": "sha512-HbDLlkDZqUMBQaiday0pJzB6/8Xx/10dI3xRebJBReOEeDSeS+7GzTtW9h8ZnfB7/wBCqvtAjGtWQLTNPbR2+g==",
"optional": true
},
"@esbuild/linux-arm": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.3.tgz",
- "integrity": "sha512-fc/T0QHMzvmnlF+kfD6bHLB8u+17gg13260p/E86yYjVoKNFjonL/+Y0GGQjMbFUas9QijqOa7pcR00a9RNkwg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.6.tgz",
+ "integrity": "sha512-C+5kb6rgsGMmvIdUI7v1PPgC98A6BMv233e97aXZ5AE03iMdlILFD/20HlHrOi0x2CzbspXn9HOnlE4/Ijn5Kw==",
"optional": true
},
"@esbuild/linux-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.3.tgz",
- "integrity": "sha512-lsKUYVd8L/j2uNs8dhMjMsKC5MHYh77gR9EThu7YCeeFz1XpIkx1I4a7mhoVfPS2VPVD1pMCh+PgxuAHUcEmXw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.6.tgz",
+ "integrity": "sha512-NMY9yg/88MskEZH2s4i6biz/3av+M8xY5ua4HE7CCz5DBz542cr7REe317+v7oKjnYBCijHpkzo5vU85bkXQmQ==",
"optional": true
},
"@esbuild/linux-ia32": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.3.tgz",
- "integrity": "sha512-EyfGWeOwRqK5Xj18vok0qv8IFBZ1/+hKV+cqD44oVhGsxHo9TmPtoSiDrWn8Sa2swq/VuO5Aiog6YPDj81oIkA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.6.tgz",
+ "integrity": "sha512-AXazA0ljvQEp7cA9jscABNXsjodKbEcqPcAE3rDzKN82Vb3lYOq6INd+HOCA7hk8IegEyHW4T72Z7QGIhyCQEA==",
"optional": true
},
"@esbuild/linux-loong64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.3.tgz",
- "integrity": "sha512-PwXkcl3t0kSeYH5RuJIeh/fHOzKZd+ZdifAWzpVO+9TLWArutTFBJvOSkTZ3CcqQqNrTj1Qyo6nqE8MQj/a7cQ==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.6.tgz",
+ "integrity": "sha512-JjBf7TwY7ldcPgHYt9UcrjZB03+WZqg/jSwMAfzOzM5ZG+tu5umUqzy5ugH/crGI4eoDIhSOTDp1NL3Uo/05Fw==",
"optional": true
},
"@esbuild/linux-mips64el": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.3.tgz",
- "integrity": "sha512-CRVkkSXf5GQcq7Am2a2tdIn85oqi/bkjuPvhNqcdeTgI0xgNbqLnEPRy2AEGkRuaJWB5uCX1IC4sqnY8ET14Yg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.6.tgz",
+ "integrity": "sha512-kATNsslryVxcH1sO3KP2nnyUWtZZVkgyhAUnyTVVa0OQQ9pmDRjTpHaE+2EQHoCM5wt/uav2edrAUqbwn3tkKQ==",
"optional": true
},
"@esbuild/linux-ppc64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.3.tgz",
- "integrity": "sha512-t7zK1Cheh0xvzfZbimztiE0wGnpV+YRsBg3tefcEBN3O4GzgLu6fFpA5HxEyVm3hHZW1jAC4OhoGEp7C5Ii6Eg==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.6.tgz",
+ "integrity": "sha512-B+wTKz+8pi7mcWXFQV0LA79dJ+qhiut5uK9q0omoKnq8yRIwQJwfg3/vclXoqqcX89Ri5Y5538V0Se2v5qlcLA==",
"optional": true
},
"@esbuild/linux-riscv64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.3.tgz",
- "integrity": "sha512-fUZPtyCYih6y4lDYdSM4Yoax4nS7aH0/XixJStys+9tfp5cAlIAZhEVKOOdeGXmQn0IEyiUtlIsPnfObbeDQfQ==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.6.tgz",
+ "integrity": "sha512-h44RBLVXFUSjvhOfseE+5UxQ/r9LVeqK2S8JziJKOm9W7SePYRPDyn7MhzhNCCFPkcjIy+soCxfhlJXHXXCR0A==",
"optional": true
},
"@esbuild/linux-s390x": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.3.tgz",
- "integrity": "sha512-oIcK2LqHWqfMERqjvaKJ3QJmycHn723HsXIv5gH4iGfmePfSj+gi0ZQv2h4bHUg2bs2gJtV0DlIjGhEuvdgxLw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.6.tgz",
+ "integrity": "sha512-FlYpyr2Xc2AUePoAbc84NRV+mj7xpsISeQ36HGf9etrY5rTBEA+IU9HzWVmw5mDFtC62EQxzkLRj8h5Hq85yOQ==",
"optional": true
},
"@esbuild/linux-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.3.tgz",
- "integrity": "sha512-RW9lpfZ6XZ6f5to2DJPvt0f/4RXEW229Xf++quVoW+YbnPrcapIJChtD/AmZ8cK3hglO/hXxJjs21pV0/l7L5w==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.6.tgz",
+ "integrity": "sha512-Mc4EUSYwzLci77u0Kao6ajB2WbTe5fNc7+lHwS3a+vJISC/oprwURezUYu1SdWAYoczbsyOvKAJwuNftoAdjjg==",
"optional": true
},
"@esbuild/netbsd-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.3.tgz",
- "integrity": "sha512-piZ2oBoaq58pKZvhgdV6PemlL30Uhd9GmmOkIGZYgChwNcyVSSl6iMEJxMzU7x44Lk9q+hJ6a343M/iVEMEvxA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.6.tgz",
+ "integrity": "sha512-3hgZlp7NqIM5lNG3fpdhBI5rUnPmdahraSmwAi+YX/bp7iZ7mpTv2NkypGs/XngdMtpzljICxnUG3uPfqLFd3w==",
"optional": true
},
"@esbuild/openbsd-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.3.tgz",
- "integrity": "sha512-vaMfouYTz/4tKdQsXDccqhV6wgPEr+hfuxdNU5Pl/vQxYTsqcXv5DYEa5Z1RAxCoua5aEB+Uj5V7VT/bM92wxw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.6.tgz",
+ "integrity": "sha512-aEWTdZQHtSRROlDYn7ygB8yAqtnall/UnmoVIJVqccKitkAWVVSYocQUWrBOxLEFk8XdlRouVrLZe6WXszyviA==",
"optional": true
},
"@esbuild/sunos-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.3.tgz",
- "integrity": "sha512-Fa3rNQQ9q1qwy9u2cdDvuGKy3jmPnPPMDdyy/qbn5d395Pb9hjLYiPzX9BozXMPJDlCNofSY7jN3miM9gyAdHA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.6.tgz",
+ "integrity": "sha512-uxk/5yAGpjKZUHOECtI9W+9IcLjKj+2m0qf+RG7f7eRBHr8wP6wsr3XbNbgtOD1qSpPapd6R2ZfSeXTkCcAo5g==",
"optional": true
},
"@esbuild/win32-arm64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.3.tgz",
- "integrity": "sha512-LPJC8ub+9uzyC6ygVmp00dAqet1q1DsZ/OldGIIBt+y+Ctd1OfnKNlzQgXK8nxwY1G8fAhklFSeSRRgAUJnR0w==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.6.tgz",
+ "integrity": "sha512-oXlXGS9zvNCGoAT/tLHAsFKrIKye1JaIIP0anCdpaI+Dc10ftaNZcqfLzEwyhdzFAYInXYH4V7kEdH4hPyo9GA==",
"optional": true
},
"@esbuild/win32-ia32": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.3.tgz",
- "integrity": "sha512-WtUyRspyxZR6NTc2HG4xd9Wvz8lP4C6OUY1gAqisrf151HvXIxsK0mfAacFJNS7EN2wvPTgjP+SM8vgBOx5+zA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.6.tgz",
+ "integrity": "sha512-qh7IcAHUvvmMBmoIG+V+BbE9ZWSR0ohF51e5g8JZvU08kZF58uDFL5tHs0eoYz31H6Finv17te3W3QB042GqVA==",
"optional": true
},
"@esbuild/win32-x64": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.3.tgz",
- "integrity": "sha512-Z8qCK4BkBm40j5KUM4NrkxYQS0R12cBO1NBVtI4vws6uwh1n/VaNu31Hm+n2cJUWdFbfH57PBghkhm9yLgmPfw==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.6.tgz",
+ "integrity": "sha512-9UDwkz7Wlm4N9jnv+4NL7F8vxLhSZfEkRArz2gD33HesAFfMLGIGNVXRoIHtWNw8feKsnGly9Hq1EUuRkWl0zA==",
"optional": true
},
"@jridgewell/gen-mapping": {
@@ -7896,32 +7896,32 @@
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
},
"esbuild": {
- "version": "0.18.3",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.3.tgz",
- "integrity": "sha512-eadWJC4CRpj93+miO5ZBlvCv+m2x6pzyNBznTvUeLFObMmxs1IMd8cCf6qiDVEZuDL6W8W7u+ZNW3GKEfOdDsA==",
+ "version": "0.18.6",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.6.tgz",
+ "integrity": "sha512-5QgxWaAhU/tPBpvkxUmnFv2YINHuZzjbk0LeUUnC2i3aJHjfi5yR49lgKgF7cb98bclOp/kans8M5TGbGFfJlQ==",
"requires": {
- "@esbuild/android-arm": "0.18.3",
- "@esbuild/android-arm64": "0.18.3",
- "@esbuild/android-x64": "0.18.3",
- "@esbuild/darwin-arm64": "0.18.3",
- "@esbuild/darwin-x64": "0.18.3",
- "@esbuild/freebsd-arm64": "0.18.3",
- "@esbuild/freebsd-x64": "0.18.3",
- "@esbuild/linux-arm": "0.18.3",
- "@esbuild/linux-arm64": "0.18.3",
- "@esbuild/linux-ia32": "0.18.3",
- "@esbuild/linux-loong64": "0.18.3",
- "@esbuild/linux-mips64el": "0.18.3",
- "@esbuild/linux-ppc64": "0.18.3",
- "@esbuild/linux-riscv64": "0.18.3",
- "@esbuild/linux-s390x": "0.18.3",
- "@esbuild/linux-x64": "0.18.3",
- "@esbuild/netbsd-x64": "0.18.3",
- "@esbuild/openbsd-x64": "0.18.3",
- "@esbuild/sunos-x64": "0.18.3",
- "@esbuild/win32-arm64": "0.18.3",
- "@esbuild/win32-ia32": "0.18.3",
- "@esbuild/win32-x64": "0.18.3"
+ "@esbuild/android-arm": "0.18.6",
+ "@esbuild/android-arm64": "0.18.6",
+ "@esbuild/android-x64": "0.18.6",
+ "@esbuild/darwin-arm64": "0.18.6",
+ "@esbuild/darwin-x64": "0.18.6",
+ "@esbuild/freebsd-arm64": "0.18.6",
+ "@esbuild/freebsd-x64": "0.18.6",
+ "@esbuild/linux-arm": "0.18.6",
+ "@esbuild/linux-arm64": "0.18.6",
+ "@esbuild/linux-ia32": "0.18.6",
+ "@esbuild/linux-loong64": "0.18.6",
+ "@esbuild/linux-mips64el": "0.18.6",
+ "@esbuild/linux-ppc64": "0.18.6",
+ "@esbuild/linux-riscv64": "0.18.6",
+ "@esbuild/linux-s390x": "0.18.6",
+ "@esbuild/linux-x64": "0.18.6",
+ "@esbuild/netbsd-x64": "0.18.6",
+ "@esbuild/openbsd-x64": "0.18.6",
+ "@esbuild/sunos-x64": "0.18.6",
+ "@esbuild/win32-arm64": "0.18.6",
+ "@esbuild/win32-ia32": "0.18.6",
+ "@esbuild/win32-x64": "0.18.6"
}
},
"escalade": {
diff --git a/package.json b/package.json
index 55711a0..e154b97 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.3",
+ "esbuild": "^0.18.6",
"fs-cheerio": "^3.0.0",
"inline-source": "^8.0.2",
"jsdom": "^22.1.0",
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 eae0637..56754df 100644
--- a/router.go
+++ b/router.go
@@ -101,6 +101,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
if app.URLBase != "" {
routePrefixes = append(routePrefixes, "")
}
+
+ userPageEnabled := app.config.Section("user_page").Key("enabled").MustBool(true) && app.config.Section("ui").Key("jellyfin_login").MustBool(true)
+
for _, p := range routePrefixes {
router.GET(p+"/lang/:page", app.GetLanguages)
router.Use(static.Serve(p+"/", app.webFS))
@@ -140,6 +143,13 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/invite/:invCode/matrix/user", app.MatrixSendPIN)
router.POST(p+"/users/matrix", app.MatrixConnect)
}
+ if userPageEnabled {
+ 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)
+ router.POST(p+"/my/password/reset/:address", app.ResetMyPassword)
+ }
}
if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
@@ -147,7 +157,14 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
}
+
api := router.Group("/", app.webAuth())
+
+ var user *gin.RouterGroup
+ if userPageEnabled {
+ user = router.Group("/my", app.userAuth())
+ }
+
for _, p := range routePrefixes {
router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers)
@@ -180,10 +197,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)
@@ -210,6 +227,22 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
}
api.POST(p+"/matrix/login", app.MatrixLogin)
+ if userPageEnabled {
+ user.GET(p+"/details", app.MyDetails)
+ 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)
+ user.GET(p+"/telegram/verified/:pin", app.MyTelegramVerifiedInvite)
+ user.POST(p+"/matrix/user", app.MatrixSendMyPIN)
+ 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)
+ user.POST(p+"/password", app.ChangeMyPassword)
+ }
}
}
diff --git a/scripts/generate_ini.py b/scripts/generate_ini.py
index edfb6ca..e47cc1c 100644
--- a/scripts/generate_ini.py
+++ b/scripts/generate_ini.py
@@ -21,6 +21,8 @@ def generate_ini(base_file, ini_file):
if "meta" in config_base["sections"][section]:
ini.set(section, fix_description(config_base["sections"][section]["meta"]["description"]))
for entry in config_base["sections"][section]["settings"]:
+ if config_base["sections"][section]["settings"][entry]["type"] == "note":
+ continue
if "description" in config_base["sections"][section]["settings"][entry]:
ini.set(section, fix_description(config_base["sections"][section]["settings"][entry]["description"]))
value = config_base["sections"][section]["settings"][entry]["value"]
diff --git a/scripts/langmover/README.md b/scripts/langmover/README.md
new file mode 100644
index 0000000..d054580
--- /dev/null
+++ b/scripts/langmover/README.md
@@ -0,0 +1,33 @@
+# *langmover*
+
+* Makes moving strings between language files a little easier.
+
+# Usage
+
+You'll need to create a template file. See example `template.json`:
+```json
+{
+ "meta": {
+ "explanation": "values here can either be folder, folder:section, or folder:section:subkey, and then either nothing, or /keyname. It all depends on whether the sections and keys match up, or if you want to pull a plural/singular only or not."
+ },
+ "strings": {
+ "inviteInfiniteUsesWarning": "admin", // Resolves to admin/strings/inviteInfiniteUsesWarning
+ "emailAddress": "form:strings/emailAddress", // Resolves to form/strings/emailAddress
+ "modifySettingsFor": "admin:quantityStrings:plural/", // Resolves to admin/quantityStrings/modifySettingsFor/plural
+ "deleteNUsers": "admin:quantityStrings:singular/deleteNUsers" // Resolves to admin/quantityStrings/deleteNUsers/singular
+ },
+ "quantityStrings": {
+ "reEnableUsers": "admin" // Resolves to admin/quantityStrings/reEnableUsers
+ }
+
+}
+```
+
+
+Args:
+* `--source`: Source `lang/` directory. **Always run on a copy, to avoid data loss**
+* `--template`: Template JSON file.
+* `--output`: Output directory. Will be filled with lang files (e.g. "en-us.json", "fa-ir.json", ...).
+* `--extract`: Passing will remove the templated strings from their source file. **Modifies the source directory**.
+
+
diff --git a/scripts/langmover/common.json b/scripts/langmover/common.json
new file mode 100644
index 0000000..3d078d3
--- /dev/null
+++ b/scripts/langmover/common.json
@@ -0,0 +1,55 @@
+{
+ "meta": {
+ "name": "English (US)"
+ },
+ "strings": {
+ "username": "common",
+ "password": "common",
+ "emailAddress": "common",
+ "name": "common",
+ "submit": "common",
+ "send": "common",
+ "success": "common",
+ "continue": "common",
+ "error": "common",
+ "copy": "common",
+ "copied": "common",
+ "time24h": "common",
+ "time12h": "common",
+ "linkTelegram": "common",
+ "contactEmail": "common",
+ "contactTelegram": "common",
+ "linkDiscord": "common",
+ "linkMatrix": "common",
+ "contactDiscord": "common",
+ "theme": "common",
+ "refresh": "common",
+ "required": "common",
+ "login": "common",
+ "logout": "common",
+ "admin": "common",
+ "enabled": "common",
+ "disabled": "common",
+ "reEnable": "common",
+ "disable": "common",
+ "contactMethods": "common",
+ "accountStatus": "common",
+ "notSet": "common",
+ "expiry": "common",
+ "add": "common",
+ "edit": "common",
+ "delete": "admin"
+ },
+ "notifications": {
+ "errorLoginBlank": "common",
+ "errorConnection": "common",
+ "errorUnknown": "common",
+ "error401Unauthorized": "common",
+ "errorSaveSettings": "common"
+ },
+ "quantityStrings": {
+ "year": "common",
+ "month": "common",
+ "day": "common"
+ }
+}
diff --git a/scripts/langmover/langmover.py b/scripts/langmover/langmover.py
new file mode 100644
index 0000000..6ef5867
--- /dev/null
+++ b/scripts/langmover/langmover.py
@@ -0,0 +1,138 @@
+import json, argparse, os
+from pathlib import Path
+
+ROOT = "en-us.json"
+
+# Tree structure: //
+def generateTree(src: Path):
+ tree = {}
+ langs = {}
+ directories = []
+
+ def readLangFile(path: Path):
+ with open(path, 'r') as f:
+ content = json.load(f)
+
+ return content
+
+
+ for directory in os.scandir(src):
+ if not directory.is_dir(): continue
+ directories.append(directory.name)
+
+ # tree[directory.name] = {}
+
+ for lang in os.scandir(directory.path):
+ if not lang.is_file(): continue
+ if not ".json" in lang.name: continue
+ if lang.name not in langs:
+ langs[lang.name] = True
+ if lang.name.lower() not in tree:
+ tree[lang.name.lower()] = {}
+
+ for lang in langs:
+ for directory in directories:
+ filepath = Path(src) / Path(directory) / Path(lang)
+ if not filepath.exists(): continue
+ tree[lang.lower()][directory] = readLangFile(filepath)
+
+ return tree
+
+def parseKey(langTree, currentSection: str, fieldName: str, key: str, extract=False):
+ temp = key.split("/")
+ loc = temp[0]
+ k = ""
+ if len(temp) > 1:
+ k = temp[1]
+
+ sections = loc.split(":")
+
+ # folder, folder:section or folder:section:subkey
+ folder = sections[0]
+ section = currentSection
+ subkey = None
+ if len(sections) > 1:
+ section = sections[1]
+ if len(sections) > 2:
+ subkey = sections[2]
+
+
+ if k == '':
+ k = fieldName
+
+ value = ""
+ if folder in langTree and section in langTree[folder] and k in langTree[folder][section]:
+ value = langTree[folder][section][k]
+ if extract:
+ s = langTree[folder][section]
+ del s[k]
+ langTree[folder][section] = s
+
+ if subkey is not None and subkey in value:
+ value = value[subkey]
+
+ return (langTree, folder, value)
+
+
+def generate(templ: Path, source: Path, output: Path, extract: str, tree):
+ with open(templ, "r") as f:
+ template = json.load(f)
+
+ if not output.exists():
+ output.mkdir()
+
+ for lang in tree:
+ out = {}
+ for section in template:
+ if section == "meta":
+ # grab a meta section from the first file we find
+ for file in tree[lang]:
+ out["meta"] = tree[lang][file]["meta"]
+ break
+
+ continue
+
+ folder = ""
+ out[section] = {}
+ for key in template[section]:
+ (tree[lang], folder, val) = parseKey(tree[lang], section, key, template[section][key], extract)
+ if val != "":
+ out[section][key] = val
+
+ # if extract and val != "":
+ # with open(source / folder / lang, "w") as f:
+ # json.dump(modifiedTree[folder], f, indent=4, ensure_ascii=False)
+
+ with open(output / Path(lang), "w") as f:
+ json.dump(out, f, indent=4, ensure_ascii=False)
+
+ if extract and extract != "":
+ ex = Path(extract)
+ if not ex.exists():
+ ex.mkdir()
+
+ for lang in tree:
+ for folder in tree[lang]:
+ if not (ex / folder).exists():
+ (ex / folder).mkdir()
+ with open(ex / folder / lang, "w") as f:
+ json.dump(tree[lang][folder], f, indent=4, ensure_ascii=False)
+
+
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument("--source", help="source \"lang/\" folder.")
+parser.add_argument("--template", help="template file. see template.json for an example of how it works.")
+parser.add_argument("--output", help="output directory for new files.")
+parser.add_argument("--extract", help="put copies of original files with strings removed in this directory")
+
+args = parser.parse_args()
+
+source = Path(args.source)
+
+tree = generateTree(source)
+
+generate(Path(args.template), source, Path(args.output), args.extract, tree)
+
+# print(json.dumps(tree, sort_keys=True, indent=4))
diff --git a/scripts/langmover/login.json b/scripts/langmover/login.json
new file mode 100644
index 0000000..7471db3
--- /dev/null
+++ b/scripts/langmover/login.json
@@ -0,0 +1,41 @@
+{
+ "meta": {},
+ "strings": {
+ "username": "common",
+ "password": "common",
+ "emailAddress": "common",
+ "name": "common",
+ "submit": "common",
+ "send": "common",
+ "success": "common",
+ "continue": "common",
+ "error": "common",
+ "copy": "common",
+ "copied": "common",
+ "time24h": "common",
+ "time12h": "common",
+ "linkTelegram": "common",
+ "contactEmail": "common",
+ "contactTelegram": "common",
+ "linkDiscord": "common",
+ "linkMatrix": "common",
+ "contactDiscord": "common",
+ "theme": "common",
+ "refresh": "common",
+ "required": "common",
+ "login": "common",
+ "logout": "common",
+ "admin": "common",
+ "enabled": "common",
+ "disabled": "common",
+ "reEnable": "common",
+ "disable": "common"
+ },
+ "notifications": {
+ "errorLoginBlank": "common",
+ "errorConnection": "common",
+ "errorUnknown": "common",
+ "error401Unauthorized": "common",
+ "errorSaveSettings": "admin"
+ }
+}
diff --git a/scripts/langmover/template.json b/scripts/langmover/template.json
new file mode 100644
index 0000000..b72e85c
--- /dev/null
+++ b/scripts/langmover/template.json
@@ -0,0 +1,15 @@
+{
+ "meta": {
+ "explanation": "values here can either be folder, folder:section, or folder:section:subkey, and then either nothing, or /keyname. It all depends on whether the sections and keys match up, or if you want to pull a plural/singular only or not."
+ },
+ "strings": {
+ "inviteInfiniteUsesWarning": "admin",
+ "emailAddress": "form:strings/emailAddress",
+ "modifySettingsFor": "admin:quantityStrings:plural/",
+ "deleteNUsers": "admin:quantityStrings:singular/deleteNUsers"
+ },
+ "quantityStrings": {
+ "reEnableUsers": "admin"
+ }
+
+}
diff --git a/setup.go b/setup.go
index 3ccaf5b..4e67dab 100644
--- a/setup.go
+++ b/setup.go
@@ -111,7 +111,8 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
if err != nil {
return err
}
- st.lang.Common.patchCommon(&lang.Strings, index)
+ st.lang.Common.patchCommonStrings(&lang.Strings, index)
+ st.lang.Common.patchCommonNotifications(&lang.Notifications, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Setup[lang.Meta.Fallback]
diff --git a/storage.go b/storage.go
index 888b9d1..873d6c4 100644
--- a/storage.go
+++ b/storage.go
@@ -15,24 +15,189 @@ 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
- 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 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.
- customEmails customEmails
- policy mediabrowser.Policy
- configuration mediabrowser.Configuration
- lang Lang
- announcements map[string]announcementTemplate
- invitesLock, usersLock 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.
+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.storeEmails()
+ st.emailsLock.Unlock()
+}
+
+// GetDiscord returns a copy of the store.
+func (st *Storage) GetDiscord() discordStore {
+ if st.discord == nil {
+ st.discord = 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()
+ if st.discord == nil {
+ st.discord = discordStore{}
+ }
+ 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.storeDiscordUsers()
+ st.discordLock.Unlock()
+}
+
+// GetTelegram returns a copy of the store.
+func (st *Storage) GetTelegram() telegramStore {
+ if st.telegram == nil {
+ st.telegram = 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()
+ if st.telegram == nil {
+ st.telegram = telegramStore{}
+ }
+ 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.storeTelegramUsers()
+ st.telegramLock.Unlock()
+}
+
+// GetMatrix returns a copy of the store.
+func (st *Storage) GetMatrix() matrixStore {
+ if st.matrix == nil {
+ st.matrix = 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()
+ if st.matrix == nil {
+ st.matrix = matrixStore{}
+ }
+ 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.storeMatrixUsers()
+ 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 {
@@ -49,6 +214,7 @@ type DiscordUser struct {
Discriminator string
Lang string
Contact bool
+ JellyfinID string `json:"-"` // Used internally in discord.go
}
type EmailAddress struct {
@@ -59,25 +225,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 {
@@ -107,7 +278,6 @@ type Invite struct {
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
}
@@ -116,9 +286,9 @@ type Lang struct {
chosenAdminLang string
Admin adminLangs
AdminJSON map[string]string
- FormPath string
- chosenFormLang string
- Form formLangs
+ UserPath string
+ chosenUserLang string
+ User userLangs
PasswordResetPath string
chosenPWRLang string
PasswordReset pwrLangs
@@ -144,7 +314,11 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
if err != nil {
return
}
- err = st.loadLangForm(filesystems...)
+ err = st.loadLangEmail(filesystems...)
+ if err != nil {
+ return
+ }
+ err = st.loadLangUser(filesystems...)
if err != nil {
return
}
@@ -152,10 +326,6 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
if err != nil {
return
}
- err = st.loadLangEmail(filesystems...)
- if err != nil {
- return
- }
err = st.loadLangTelegram(filesystems...)
return
}
@@ -164,7 +334,7 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
// from a list of other sources in a preferred order.
// languages to patch from should be in decreasing priority,
// E.g: If to = fr-be, from = [fr-fr, en-us].
-func (common *commonLangs) patchCommon(to *langSection, from ...string) {
+func (common *commonLangs) patchCommonStrings(to *langSection, from ...string) {
if *to == nil {
*to = langSection{}
}
@@ -183,6 +353,44 @@ func (common *commonLangs) patchCommon(to *langSection, from ...string) {
}
}
+func (common *commonLangs) patchCommonNotifications(to *langSection, from ...string) {
+ if *to == nil {
+ *to = langSection{}
+ }
+ for n, ev := range (*common)[from[len(from)-1]].Notifications {
+ if v, ok := (*to)[n]; !ok || v == "" {
+ i := 0
+ for i < len(from)-1 {
+ ev, ok = (*common)[from[i]].Notifications[n]
+ if ok && ev != "" {
+ break
+ }
+ i++
+ }
+ (*to)[n] = ev
+ }
+ }
+}
+
+func (common *commonLangs) patchCommonQuantityStrings(to *map[string]quantityString, from ...string) {
+ if *to == nil {
+ *to = map[string]quantityString{}
+ }
+ for n, ev := range (*common)[from[len(from)-1]].QuantityStrings {
+ if v, ok := (*to)[n]; !ok || (v.Singular == "" && v.Plural == "") {
+ i := 0
+ for i < len(from)-1 {
+ ev, ok = (*common)[from[i]].QuantityStrings[n]
+ if ok && ev.Singular != "" && ev.Plural != "" {
+ break
+ }
+ i++
+ }
+ (*to)[n] = ev
+ }
+ }
+}
+
func patchLang(to *langSection, from ...*langSection) {
if *to == nil {
*to = langSection{}
@@ -264,10 +472,14 @@ func (st *Storage) loadLangCommon(filesystems ...fs.FS) error {
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
+ patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
+ patchQuantityStrings(&lang.QuantityStrings, &fallback.QuantityStrings, &english.QuantityStrings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
+ patchLang(&lang.Notifications, &english.Notifications)
+ patchQuantityStrings(&lang.QuantityStrings, &english.QuantityStrings)
}
}
st.lang.Common[index] = lang
@@ -329,7 +541,8 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
if err != nil {
return err
}
- st.lang.Common.patchCommon(&lang.Strings, index)
+ st.lang.Common.patchCommonStrings(&lang.Strings, index)
+ st.lang.Common.patchCommonNotifications(&lang.Notifications, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Admin[lang.Meta.Fallback]
@@ -395,16 +608,16 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
return nil
}
-func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
- st.lang.Form = map[string]formLang{}
- var english formLang
+func (st *Storage) loadLangUser(filesystems ...fs.FS) error {
+ st.lang.User = map[string]userLang{}
+ var english userLang
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
- lang := formLang{}
- f, err := fs.ReadFile(filesystem, FSJoin(st.lang.FormPath, fname))
+ lang := userLang{}
+ f, err := fs.ReadFile(filesystem, FSJoin(st.lang.UserPath, fname))
if err != nil {
return err
}
@@ -415,14 +628,21 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if err != nil {
return err
}
- st.lang.Common.patchCommon(&lang.Strings, index)
+ st.lang.Common.patchCommonStrings(&lang.Strings, index)
+ st.lang.Common.patchCommonNotifications(&lang.Notifications, index)
+ st.lang.Common.patchCommonQuantityStrings(&lang.QuantityStrings, index)
+ // turns out, a lot of email strings are useful on the user page.
+ emailLang := []langSection{st.lang.Email[index].WelcomeEmail, st.lang.Email[index].UserDisabled, st.lang.Email[index].UserExpired}
+ for _, v := range emailLang {
+ patchLang(&lang.Strings, &v)
+ }
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
- fallback, ok := st.lang.Form[lang.Meta.Fallback]
+ fallback, ok := st.lang.User[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
- fallback = st.lang.Form[lang.Meta.Fallback]
+ fallback = st.lang.User[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
@@ -445,9 +665,14 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if err != nil {
return err
}
+ userJSON, err := json.Marshal(lang)
+ if err != nil {
+ return err
+ }
lang.notificationsJSON = string(notifications)
lang.validationStringsJSON = string(validationStrings)
- st.lang.Form[index] = lang
+ lang.JSON = string(userJSON)
+ st.lang.User[index] = lang
return nil
}
engFound := false
@@ -463,10 +688,10 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if !engFound {
return err
}
- english = st.lang.Form["en-us"]
- formLoaded := false
+ english = st.lang.User["en-us"]
+ userLoaded := false
for i := range filesystems {
- files, err := fs.ReadDir(filesystems[i], st.lang.FormPath)
+ files, err := fs.ReadDir(filesystems[i], st.lang.UserPath)
if err != nil {
continue
}
@@ -474,13 +699,13 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
- formLoaded = true
+ userLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
}
- if !formLoaded {
+ if !userLoaded {
return err
}
return nil
@@ -506,7 +731,7 @@ func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
if err != nil {
return err
}
- st.lang.Common.patchCommon(&lang.Strings, index)
+ st.lang.Common.patchCommonStrings(&lang.Strings, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.PasswordReset[lang.Meta.Fallback]
@@ -540,7 +765,7 @@ func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
return err
}
english = st.lang.PasswordReset["en-us"]
- formLoaded := false
+ userLoaded := false
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.PasswordResetPath)
if err != nil {
@@ -550,13 +775,13 @@ func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
- formLoaded = true
+ userLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
}
- if !formLoaded {
+ if !userLoaded {
return err
}
return nil
@@ -582,7 +807,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
if err != nil {
return err
}
- st.lang.Common.patchCommon(&lang.Strings, index)
+ st.lang.Common.patchCommonStrings(&lang.Strings, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Email[lang.Meta.Fallback]
@@ -679,7 +904,7 @@ func (st *Storage) loadLangTelegram(filesystems ...fs.FS) error {
if err != nil {
return err
}
- st.lang.Common.patchCommon(&lang.Strings, index)
+ st.lang.Common.patchCommonStrings(&lang.Strings, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Telegram[lang.Meta.Fallback]
@@ -739,14 +964,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)
}
@@ -813,6 +1034,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/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/telegram.go b/telegram.go
index 654d05b..a50292c 100644
--- a/telegram.go
+++ b/telegram.go
@@ -9,10 +9,20 @@ 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
+ 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 {
@@ -20,9 +30,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]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
}
@@ -40,13 +50,13 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
ShutdownChannel: make(chan string),
bot: bot,
username: bot.Self.UserName,
- tokens: []string{},
- verifiedTokens: []TelegramVerifiedToken{},
+ tokens: map[string]VerifToken{},
+ verifiedTokens: map[string]TelegramVerifiedToken{},
languages: map[int64]string{},
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
}
@@ -72,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 = append(t.tokens, pin)
+ 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
}
@@ -198,10 +216,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)
}
@@ -212,29 +230,58 @@ 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 {
+ 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)
}
+ 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,
- 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]
+ t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{
+ ChatID: upd.Message.Chat.ID,
+ Username: upd.Message.Chat.UserName,
+ JellyfinID: token.JellyfinID,
+ }
+ 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
+}
+
+// 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
+ 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)
}
diff --git a/ts/admin.ts b/ts/admin.ts
index 9283692..df65535 100644
--- a/ts/admin.ts
+++ b/ts/admin.ts
@@ -1,4 +1,4 @@
-import { nightwind } from "./modules/theme.js";
+import { ThemeManager } from "./modules/theme.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { Modal } from "./modules/modal.js";
import { Tabs } from "./modules/tabs.js";
@@ -6,34 +6,11 @@ import { inviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { ProfileEditor } from "./modules/profiles.js";
-import { _get, _post, notificationBox, whichAnimationEvent, toggleLoader } from "./modules/common.js";
+import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
import { Updater } from "./modules/update.js";
+import { Login } from "./modules/login.js";
-let theme = new nightwind();
-
-const themeButton = document.getElementById('button-theme') as HTMLSpanElement;
-const switchThemeIcon = () => {
- const icon = themeButton.childNodes[0] as HTMLElement;
- if (document.documentElement.classList.contains("dark")) {
- icon.classList.add("ri-sun-line");
- icon.classList.remove("ri-moon-line");
- themeButton.classList.add("~warning");
- themeButton.classList.remove("~neutral");
- themeButton.classList.remove("@high");
- } else {
- icon.classList.add("ri-moon-line");
- icon.classList.remove("ri-sun-line");
- themeButton.classList.add("@high");
- themeButton.classList.add("~neutral");
- themeButton.classList.remove("~warning");
- }
-};
- themeButton.onclick = () => {
- theme.toggle();
- switchThemeIcon();
- }
-switchThemeIcon();
-
+const theme = new ThemeManager(document.getElementById("button-theme"));
window.lang = new lang(window.langFile as LangFile);
loadLangSelector("admin");
@@ -129,19 +106,35 @@ window.notifications = new notificationBox(document.getElementById('notification
}*/
// load tabs
-window.tabs = new Tabs();
-window.tabs.addTab("invites", null, window.invites.reload);
-window.tabs.addTab("accounts", null, accounts.reload);
-window.tabs.addTab("settings", null, settings.reload);
+const tabs: { url: string, reloader: () => void }[] = [
+ {
+ url: "invites",
+ reloader: window.invites.reload
+ },
+ {
+ url: "accounts",
+ reloader: accounts.reload
+ },
+ {
+ url: "settings",
+ reloader: settings.reload
+ }
+];
-for (let tab of ["invites", "accounts", "settings"]) {
- if (window.location.pathname == window.URLBase + "/" + tab) {
- window.tabs.switch(tab, true);
+const defaultTab = tabs[0];
+
+window.tabs = new Tabs();
+
+for (let tab of tabs) {
+ window.tabs.addTab(tab.url, null, tab.reloader);
+ if (window.location.pathname == window.URLBase + "/" + tab.url) {
+ window.tabs.switch(tab.url, true);
}
}
+// Default tab
if ((window.URLBase + "/").includes(window.location.pathname)) {
- window.tabs.switch("invites", true);
+ window.tabs.switch(defaultTab.url, true);
}
document.addEventListener("tab-change", (event: CustomEvent) => {
@@ -165,80 +158,25 @@ window.onpopstate = (event: PopStateEvent) => {
window.tabs.switch(event.state);
}
-function login(username: string, password: string, run?: (state?: number) => void) {
- const req = new XMLHttpRequest();
- req.responseType = 'json';
- let url = window.URLBase;
- const refresh = (username == "" && password == "");
- if (refresh) {
- url += "/token/refresh";
- } else {
- url += "/token/login";
+const login = new Login(window.modals.login as Modal, "/");
+login.onLogin = () => {
+ console.log("Logged in.");
+ window.updater = new Updater();
+ setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
+ const currentTab = window.tabs.current;
+ switch (currentTab) {
+ case "invites":
+ window.invites.reload();
+ break;
+ case "accounts":
+ accounts.reload();
+ break;
+ case "settings":
+ settings.reload();
+ break;
}
- req.open("GET", url, true);
- if (!refresh) {
- req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
- }
- req.onreadystatechange = function (): void {
- if (this.readyState == 4) {
- if (this.status != 200) {
- let errorMsg = window.lang.notif("errorConnection");
- if (this.response) {
- errorMsg = this.response["error"];
- }
- if (!errorMsg) {
- errorMsg = window.lang.notif("errorUnknown");
- }
- if (!refresh) {
- window.notifications.customError("loginError", errorMsg);
- } else {
- window.modals.login.show();
- }
- } else {
- const data = this.response;
- window.token = data["token"];
- window.updater = new Updater(); // mmm, a race condition
- window.modals.login.close();
- setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
- const currentTab = window.tabs.current;
- switch (currentTab) {
- case "invites":
- window.invites.reload();
- break;
- case "accounts":
- accounts.reload();
- break;
- case "settings":
- settings.reload();
- break;
- }
- document.getElementById("logout-button").classList.remove("unfocused");
- }
- if (run) { run(+this.status); }
- }
- };
- req.send();
}
-(document.getElementById('form-login') as HTMLFormElement).onsubmit = (event: SubmitEvent) => {
- event.preventDefault();
- const button = (event.target as HTMLElement).querySelector(".submit") as HTMLSpanElement;
- const username = (document.getElementById("login-user") as HTMLInputElement).value;
- const password = (document.getElementById("login-password") as HTMLInputElement).value;
- if (!username || !password) {
- window.notifications.customError("loginError", window.lang.notif("errorLoginBlank"));
- return;
- }
- toggleLoader(button);
- login(username, password, () => toggleLoader(button));
-};
+login.bindLogout(document.getElementById("logout-button"));
-login("", "");
-
-(document.getElementById('logout-button') as HTMLButtonElement).onclick = () => _post("/logout", null, (req: XMLHttpRequest): boolean => {
- if (req.readyState == 4 && req.status == 200) {
- window.token = "";
- location.reload();
- return false;
- }
-});
+login.login("", "");
diff --git a/ts/form.ts b/ts/form.ts
index 31cdf0d..5610430 100644
--- a/ts/form.ts
+++ b/ts/form.ts
@@ -2,7 +2,8 @@ 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, ValidatorRespDTO } from "./modules/validator.js";
+import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
interface formWindow extends Window {
invalidPassword: string;
@@ -34,6 +35,8 @@ interface formWindow extends Window {
captcha: boolean;
reCAPTCHA: boolean;
reCAPTCHASiteKey: string;
+ userPageEnabled: boolean;
+ userPageAddress: string;
}
loadLangSelector("form");
@@ -49,112 +52,63 @@ 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 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();
- };
-}
-interface DiscordInvite {
- invite: string;
- icon: string;
+ const telegramConf: ServiceConfiguration = {
+ modal: window.telegramModal as Modal,
+ pin: window.telegramPIN,
+ pinURL: "",
+ verifiedURL: "/invite/" + window.code + "/telegram/verified/",
+ invalidCodeError: window.messages["errorInvalidPIN"],
+ 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;
+ validator.validate();
+ }
+ };
+
+ const telegram = new Telegram(telegramConf);
+
+ telegramButton.onclick = () => { telegram.onclick(); };
}
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}`;
- }
- });
- }
- 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 discordConf: ServiceConfiguration = {
+ 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["errorInvalidPIN"],
+ accountLinkedError: window.messages["errorAccountLinked"],
+ successError: window.messages["verified"],
+ successFunc: (modalClosed: boolean) => {
+ 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;
+ validator.validate();
}
- 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;
@@ -162,69 +116,31 @@ 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"],
+ unknownError: window.messages["errorUnknown"],
+ 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;
+ validator.validate();
}
};
+
+ const matrix = new Matrix(matrixConf);
+
+ matrixButton.onclick = () => { matrix.show(); };
}
if (window.confirmation) {
@@ -247,7 +163,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;
@@ -262,33 +178,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);
@@ -307,18 +221,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 {
@@ -336,17 +243,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)
-}
-
-interface respDTO {
- response: boolean;
- error: string;
+ emailField.addEventListener("keyup", validator.validate)
}
interface sendDTO {
@@ -381,7 +290,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) => {
@@ -427,17 +336,22 @@ 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) {
const url = ((document.getElementById("modal-success") as HTMLDivElement).querySelector("a.submit") as HTMLAnchorElement).href;
window.location.href = url;
} else {
+ if (window.userPageEnabled) {
+ const userPageNoticeArea = document.getElementById("modal-success-user-page-area");
+ const link = `${userPageNoticeArea.getAttribute("my-account-term")}`;
+ userPageNoticeArea.innerHTML = userPageNoticeArea.textContent.replace("{myAccount}", link);
+ }
window.successModal.show();
}
} else {
@@ -476,6 +390,6 @@ const create = (event: SubmitEvent) => {
});
};
-validatorFunc();
+validator.validate();
form.onsubmit = create;
diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts
new file mode 100644
index 0000000..bdc92be
--- /dev/null
+++ b/ts/modules/account-linking.ts
@@ -0,0 +1,269 @@
+import { Modal } from "../modules/modal.js";
+import { _get, _post, toggleLoader, addLoader, removeLoader } 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 ServiceConfiguration {
+ 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 ServiceLinker {
+ protected _conf: ServiceConfiguration;
+ protected _pinAcquired = false;
+ protected _modalClosed = false;
+ protected _waiting: HTMLSpanElement;
+ protected _verified = false;
+ protected _name: string;
+ protected _pin: string;
+
+ get verified(): boolean { return this._verified; }
+
+ constructor(conf: ServiceConfiguration) {
+ this._conf = conf;
+ this._conf.modal.onclose = () => {
+ this._modalClosed = true;
+ toggleLoader(this._waiting);
+ };
+ }
+
+ protected _checkVerified = () => {
+ if (this._modalClosed) return;
+ if (!this._pinAcquired) {
+ setTimeout(this._checkVerified, 1500);
+ return;
+ }
+ _get(this._conf.verifiedURL + this._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(this._name + "Verified", "", 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() {
+ toggleLoader(this._waiting);
+
+ this._pinAcquired = false;
+ this._pin = "";
+ if (this._conf.pin) {
+ this._pinAcquired = true;
+ 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._pin = req.response["pin"];
+ this._conf.modal.modal.querySelector(".pin").textContent = this._pin;
+ this._pinAcquired = true;
+ }
+ });
+ }
+
+ this._modalClosed = false;
+ this._conf.modal.show();
+
+ 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;
+ }
+};
+
+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._input.value = "";
+ 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 = () => _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;
+ 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/modules/login.ts b/ts/modules/login.ts
new file mode 100644
index 0000000..c9248c4
--- /dev/null
+++ b/ts/modules/login.ts
@@ -0,0 +1,90 @@
+import { Modal } from "../modules/modal.js";
+import { toggleLoader, _post } from "../modules/common.js";
+
+export class Login {
+ private _modal: Modal;
+ private _form: HTMLFormElement;
+ private _url: string;
+ private _onLogin: (username: string, password: string) => void;
+ private _logoutButton: HTMLElement = null;
+
+ constructor(modal: Modal, endpoint: string) {
+
+ this._url = window.URLBase + endpoint;
+ if (this._url[this._url.length-1] != '/') this._url += "/";
+
+ this._modal = modal;
+ this._form = this._modal.asElement().querySelector(".form-login") as HTMLFormElement;
+ this._form.onsubmit = (event: SubmitEvent) => {
+ event.preventDefault();
+ const button = (event.target as HTMLElement).querySelector(".submit") as HTMLSpanElement;
+ const username = (document.getElementById("login-user") as HTMLInputElement).value;
+ const password = (document.getElementById("login-password") as HTMLInputElement).value;
+ if (!username || !password) {
+ window.notifications.customError("loginError", window.lang.notif("errorLoginBlank"));
+ return;
+ }
+ toggleLoader(button);
+ this.login(username, password, () => toggleLoader(button));
+ };
+ }
+
+ bindLogout = (button: HTMLElement) => {
+ this._logoutButton = button;
+ this._logoutButton.classList.add("unfocused");
+ this._logoutButton.onclick = () => _post(this._url + "logout", null, (req: XMLHttpRequest): boolean => {
+ if (req.readyState == 4 && req.status == 200) {
+ window.token = "";
+ location.reload();
+ return false;
+ }
+ });
+ };
+
+ get onLogin() { return this._onLogin; }
+ set onLogin(f: (username: string, password: string) => void) { this._onLogin = f; }
+
+ login = (username: string, password: string, run?: (state?: number) => void) => {
+ const req = new XMLHttpRequest();
+ req.responseType = 'json';
+ const refresh = (username == "" && password == "");
+ req.open("GET", this._url + (refresh ? "token/refresh" : "token/login"), true);
+ if (!refresh) {
+ req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
+ }
+ req.onreadystatechange = ((req: XMLHttpRequest, _: Event): any => {
+ if (req.readyState == 4) {
+ if (req.status != 200) {
+ let errorMsg = window.lang.notif("errorConnection");
+ if (req.response) {
+ errorMsg = req.response["error"];
+ const langErrorMsg = window.lang.strings(errorMsg);
+ if (langErrorMsg) {
+ errorMsg = langErrorMsg;
+ }
+ }
+ if (!errorMsg) {
+ errorMsg = window.lang.notif("errorUnknown");
+ }
+ if (!refresh) {
+ window.notifications.customError("loginError", errorMsg);
+ } else {
+ this._modal.show();
+ }
+ } else {
+ const data = req.response;
+ window.token = data["token"];
+ if (this._onLogin) {
+ this._onLogin(username, password);
+ }
+ this._modal.close();
+ if (this._logoutButton != null)
+ this._logoutButton.classList.remove("unfocused");
+ }
+ if (run) { run(+req.status); }
+ }
+ }).bind(this, req);
+ req.send();
+ };
+}
+
diff --git a/ts/modules/modal.ts b/ts/modules/modal.ts
index dcd2ad8..e8079dd 100644
--- a/ts/modules/modal.ts
+++ b/ts/modules/modal.ts
@@ -54,4 +54,6 @@ export class Modal implements Modal {
this.show();
}
}
+
+ asElement = () => { return this.modal; }
}
diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts
index 6c5282e..2c12fac 100644
--- a/ts/modules/settings.ts
+++ b/ts/modules/settings.ts
@@ -453,6 +453,78 @@ class DOMSelect implements SSelect {
asElement = (): HTMLDivElement => { return this._container; }
}
+interface SNote extends Setting {
+ value: string;
+ style?: string;
+}
+class DOMNote implements SNote {
+ private _container: HTMLDivElement;
+ private _aside: HTMLElement;
+ private _name: HTMLElement;
+ private _description: HTMLElement;
+ type: string = "note";
+ private _style: string;
+
+ get name(): string { return this._name.textContent; }
+ set name(n: string) { this._name.textContent = n; }
+
+ get description(): string { return this._description.textContent; }
+ set description(d: string) { this._description.textContent = d; }
+
+ get value(): string { return ""; }
+ set value(v: string) { return; }
+
+ get required(): boolean { return false; }
+ set required(v: boolean) { return; }
+
+ get requires_restart(): boolean { return false; }
+ set requires_restart(v: boolean) { return; }
+
+ get style(): string { return this._style; }
+ set style(s: string) {
+ this._aside.classList.remove("~" + this._style);
+ this._style = s;
+ this._aside.classList.add("~" + this._style);
+ }
+
+ constructor(setting: SNote, section: string) {
+ this._container = document.createElement("div");
+ this._container.classList.add("setting");
+ this._container.innerHTML = `
+
+ `;
+ this._name = this._container.querySelector(".setting-name");
+ this._description = this._container.querySelector(".setting-description");
+ this._aside = this._container.querySelector("aside");
+
+ if (setting.depends_false || setting.depends_true) {
+ let dependant = splitDependant(section, setting.depends_true || setting.depends_false);
+ let state = true;
+ if (setting.depends_false) { state = false; }
+ document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
+ if (Boolean(event.detail) !== state) {
+ this._container.classList.add("unfocused");
+ } else {
+ this._container.classList.remove("unfocused");
+ }
+ });
+ }
+
+ this.update(setting);
+ }
+
+ update = (s: SNote) => {
+ this.name = s.name;
+ this.description = s.description;
+ this.style = ("style" in s && s.style) ? s.style : "info";
+ };
+
+ asElement = (): HTMLDivElement => { return this._container; }
+}
+
interface Section {
meta: Meta;
order: string[];
@@ -502,13 +574,18 @@ class sectionPanel {
case "select":
setting = new DOMSelect(setting as SSelect, this._sectionName, name);
break;
+ case "note":
+ setting = new DOMNote(setting as SNote, this._sectionName);
+ break;
+ }
+ if (setting.type != "note") {
+ this.values[name] = ""+setting.value;
+ document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => {
+ // const oldValue = this.values[name];
+ this.values[name] = ""+event.detail;
+ document.dispatchEvent(new CustomEvent("settings-section-changed"));
+ });
}
- this.values[name] = ""+setting.value;
- document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => {
- // const oldValue = this.values[name];
- this.values[name] = ""+event.detail;
- document.dispatchEvent(new CustomEvent("settings-section-changed"));
- });
this._section.appendChild(setting.asElement());
this._settings[name] = setting;
}
@@ -542,7 +619,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 +790,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 +801,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 +852,7 @@ interface emailListEl {
enabled: boolean;
}
-class EmailEditor {
+class MessageEditor {
private _currentID: string;
private _names: { [id: string]: emailListEl };
private _content: string;
@@ -884,8 +963,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/ts/modules/theme.ts b/ts/modules/theme.ts
index f8f190d..0767451 100644
--- a/ts/modules/theme.ts
+++ b/ts/modules/theme.ts
@@ -1,25 +1,8 @@
-export function toggleTheme() {
- document.documentElement.classList.toggle('dark');
- document.documentElement.classList.toggle('light');
- localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? "dark" : "light");
-}
+export class ThemeManager {
-export function loadTheme() {
- const theme = localStorage.getItem("theme");
- if (theme == "dark") {
- document.documentElement.classList.add('dark');
- document.documentElement.classList.remove('light');
- } else if (theme == "light") {
- document.documentElement.classList.add('light');
- document.documentElement.classList.remove('dark');
- } else if (window.matchMedia('(prefers-color-scheme: dark)').media !== 'not all') {
- document.documentElement.classList.add('dark');
- document.documentElement.classList.remove('light');
- }
-}
-
-export class nightwind {
- beforeTransition = () => {
+ private _themeButton: HTMLElement = null;
+
+ private _beforeTransition = () => {
const doc = document.documentElement;
const onTransitionDone = () => {
doc.classList.remove('nightwind');
@@ -30,19 +13,53 @@ export class nightwind {
doc.classList.add('nightwind');
}
};
- constructor() {
- const theme = localStorage.getItem("theme");
- if (theme == "dark") {
- this.enable(true);
- } else if (theme == "light") {
- this.enable(false);
- } else if (window.matchMedia('(prefers-color-scheme: dark)').media !== 'not all') {
- this.enable(true);
+
+ private _updateThemeIcon = () => {
+ const icon = this._themeButton.childNodes[0] as HTMLElement;
+ if (document.documentElement.classList.contains("dark")) {
+ icon.classList.add("ri-sun-line");
+ icon.classList.remove("ri-moon-line");
+ this._themeButton.classList.add("~warning");
+ this._themeButton.classList.remove("~neutral");
+ this._themeButton.classList.remove("@high");
+ } else {
+ icon.classList.add("ri-moon-line");
+ icon.classList.remove("ri-sun-line");
+ this._themeButton.classList.add("@high");
+ this._themeButton.classList.add("~neutral");
+ this._themeButton.classList.remove("~warning");
}
+ };
+
+ bindButton = (button: HTMLElement) => {
+ this._themeButton = button;
+ this._themeButton.onclick = this.toggle;
+ this._updateThemeIcon();
}
toggle = () => {
- this.beforeTransition();
+ this._toggle();
+ if (this._themeButton) {
+ this._updateThemeIcon();
+ }
+ }
+
+ constructor(button?: HTMLElement) {
+ const theme = localStorage.getItem("theme");
+ if (theme == "dark") {
+ this._enable(true);
+ } else if (theme == "light") {
+ this._enable(false);
+ } else if (window.matchMedia('(prefers-color-scheme: dark)').media !== 'not all') {
+ this._enable(true);
+ }
+
+ if (arguments.length == 1)
+ this.bindButton(button);
+ }
+
+ private _toggle = () => {
+ this._beforeTransition();
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark');
} else {
@@ -51,17 +68,24 @@ export class nightwind {
localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? "dark" : "light");
};
- enable = (dark: boolean) => {
+ private _enable = (dark: boolean) => {
const mode = dark ? "dark" : "light";
const opposite = dark ? "light" : "dark";
localStorage.setItem('theme', dark ? "dark" : "light");
- this.beforeTransition();
+ this._beforeTransition();
if (document.documentElement.classList.contains(opposite)) {
document.documentElement.classList.remove(opposite);
}
document.documentElement.classList.add(mode);
};
+
+ enable = (dark: boolean) => {
+ this._enable(dark);
+ if (this._themeButton != null) {
+ this._updateThemeIcon();
+ }
+ };
}
diff --git a/ts/modules/validator.ts b/ts/modules/validator.ts
index 728a800..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;
@@ -60,8 +65,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 +100,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 +135,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 +152,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/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/typings/d.ts b/ts/typings/d.ts
index 4096fb9..cae4f54 100644
--- a/ts/typings/d.ts
+++ b/ts/typings/d.ts
@@ -110,7 +110,9 @@ declare interface Modals {
discord: Modal;
matrix: Modal;
sendPWR?: Modal;
+ pwr?: Modal;
logs: Modal;
+ email?: Modal;
}
interface Invite {
diff --git a/ts/user.ts b/ts/user.ts
new file mode 100644
index 0000000..b21f555
--- /dev/null
+++ b/ts/user.ts
@@ -0,0 +1,550 @@
+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, 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, ValidatorRespDTO } from "./modules/validator.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;
+ pwrEnabled: string;
+}
+
+declare var window: userWindow;
+
+const theme = new ThemeManager(document.getElementById("button-theme"));
+
+window.lang = new lang(window.langFile as LangFile);
+
+loadLangSelector("user");
+
+window.animationEvent = whichAnimationEvent();
+
+window.token = "";
+
+window.modals = {} as Modals;
+
+(() => {
+ window.modals.login = new Modal(document.getElementById("modal-login"), true);
+ window.modals.email = new Modal(document.getElementById("modal-email"), 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);
+ }
+ 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 = () => {
+ 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();
+ }
+ }
+})();
+
+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 = () => {
+ 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");
+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;
+ enabled: boolean;
+}
+
+interface MyDetails {
+ id: string;
+ username: string;
+ expiry: number;
+ admin: boolean;
+ accounts_admin: boolean;
+ disabled: boolean;
+ email?: MyDetailsContactMethod;
+ discord?: MyDetailsContactMethod;
+ telegram?: MyDetailsContactMethod;
+ matrix?: MyDetailsContactMethod;
+}
+
+interface ContactDTO {
+ email?: boolean;
+ discord?: boolean;
+ telegram?: boolean;
+ matrix?: boolean;
+}
+
+class ContactMethods {
+ private _card: HTMLElement;
+ private _content: HTMLElement;
+ private _buttons: { [name: string]: { element: HTMLElement, details: MyDetailsContactMethod } };
+
+ constructor (card: HTMLElement) {
+ this._card = card;
+ this._content = this._card.querySelector(".content");
+ this._buttons = {};
+ }
+
+ clear = () => {
+ this._content.textContent = "";
+ this._buttons = {};
+ }
+
+ append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => {
+ const row = document.createElement("div");
+ row.classList.add("flex", "flex-expand", "my-2", "flex-nowrap");
+ let innerHTML = `
+
+
+
+ ${icon}
+
+
+ ${(details.value == "") ? window.lang.strings("notSet") : details.value}
+
+
+
+ `;
+ if (addEditFunc) {
+ innerHTML += `
+
+ `;
+ }
+
+ if (!required && details.value != "") {
+ innerHTML += `
+
+ `;
+ }
+
+ innerHTML += `
+
+ `;
+
+ row.innerHTML = innerHTML;
+
+ this._buttons[name] = {
+ element: row,
+ details: details
+ };
+
+ const button = row.querySelector(".user-contact-enabled-disabled") as HTMLButtonElement;
+ const checkbox = button.querySelector("input[type=checkbox]") as HTMLInputElement;
+ const setButtonAppearance = () => {
+ if (checkbox.checked) {
+ button.classList.add("~urge");
+ button.classList.remove("~neutral");
+ } else {
+ button.classList.add("~neutral");
+ button.classList.remove("~urge");
+ }
+ };
+ const onPress = () => {
+ this._buttons[name].details.enabled = checkbox.checked;
+ setButtonAppearance();
+ this._save();
+ };
+
+ checkbox.onchange = onPress;
+ button.onclick = () => {
+ checkbox.checked = !checkbox.checked;
+ onPress();
+ };
+
+ checkbox.checked = details.enabled;
+ setButtonAppearance();
+
+ if (addEditFunc) {
+ 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;
+ document.dispatchEvent(new CustomEvent("details-reload"));
+ });
+ }
+
+ this._content.appendChild(row);
+ };
+
+ private _save = () => {
+ let data: ContactDTO = {};
+ for (let method of Object.keys(this._buttons)) {
+ data[method] = this._buttons[method].details.enabled;
+ }
+
+ _post("/my/contact", data, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ if (req.status != 200) {
+ window.notifications.customError("errorSetNotify", window.lang.notif("errorSaveSettings"));
+ document.dispatchEvent(new CustomEvent("details-reload"));
+ }
+ }
+ });
+ };
+}
+
+class ExpiryCard {
+ private _card: HTMLElement;
+ private _expiry: Date;
+ private _aside: HTMLElement;
+ private _countdown: HTMLElement;
+ private _interval: number = null;
+
+ constructor(card: HTMLElement) {
+ this._card = card;
+ this._aside = this._card.querySelector(".user-expiry") as HTMLElement;
+ this._countdown = this._card.querySelector(".user-expiry-countdown") as HTMLElement;
+ }
+
+ private _drawCountdown = () => {
+ let now = new Date();
+ // Years, Months, Days
+ let ymd = [0, 0, 0];
+ while (now.getFullYear() != this._expiry.getFullYear()) {
+ ymd[0] += 1;
+ now.setFullYear(now.getFullYear()+1);
+ }
+ if (now.getMonth() > this._expiry.getMonth()) {
+ ymd[0] -=1;
+ now.setFullYear(now.getFullYear()-1);
+ }
+ while (now.getMonth() != this._expiry.getMonth()) {
+ ymd[1] += 1;
+ now.setMonth(now.getMonth() + 1);
+ }
+ if (now.getDate() > this._expiry.getDate()) {
+ ymd[1] -=1;
+ now.setMonth(now.getMonth()-1);
+ }
+ while (now.getDate() != this._expiry.getDate()) {
+ ymd[2] += 1;
+ now.setDate(now.getDate() + 1);
+ }
+
+ const langKeys = ["year", "month", "day"];
+ let innerHTML = ``;
+ for (let i = 0; i < langKeys.length; i++) {
+ if (ymd[i] == 0) continue;
+ const words = window.lang.quantity(langKeys[i], ymd[i]).split(" ");
+ innerHTML += `
+
+
+ ${words[0]} ${words[1]}
+
+
+ `;
+ }
+ this._countdown.innerHTML = innerHTML;
+ };
+
+ get expiry(): Date { return this._expiry; };
+ set expiry(expiryUnix: number) {
+ if (this._interval !== null) {
+ window.clearInterval(this._interval);
+ this._interval = null;
+ }
+ if (expiryUnix == 0) return;
+ this._expiry = new Date(expiryUnix * 1000);
+ this._aside.textContent = window.lang.strings("yourAccountIsValidUntil").replace("{date}", toDateString(this._expiry));
+ this._card.classList.remove("unfocused");
+
+ this._interval = window.setInterval(this._drawCountdown, 60*1000);
+ this._drawCountdown();
+ }
+}
+
+var expiryCard = new ExpiryCard(statusCard);
+
+var contactMethodList = new ContactMethods(contactCard);
+
+const addEditEmail = (add: boolean): void => {
+ 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)) {
+ document.dispatchEvent(new CustomEvent("details-reload"));
+ }
+ }, true, (req: XMLHttpRequest) => {
+ if (req.readyState == 4 && req.status == 401) {
+ content.classList.add("unfocused");
+ confirmationRequired.classList.remove("unfocused");
+ }
+ });
+ }
+
+ window.modals.email.show();
+}
+
+const discordConf: ServiceConfiguration = {
+ 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("errorInvalidPIN"),
+ accountLinkedError: window.lang.notif("errorAccountLinked"),
+ successError: window.lang.notif("verified"),
+ successFunc: (modalClosed: boolean) => {
+ if (modalClosed) document.dispatchEvent(new CustomEvent("details-reload"));
+ }
+};
+
+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("errorInvalidPIN"),
+ accountLinkedError: window.lang.notif("errorAccountLinked"),
+ successError: window.lang.notif("verified"),
+ successFunc: (modalClosed: boolean) => {
+ if (modalClosed) document.dispatchEvent(new CustomEvent("details-reload"));
+ }
+};
+
+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(() => document.dispatchEvent(new CustomEvent("details-reload")), 1200);
+ }
+};
+
+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);
+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", () => {
+ _get("/my/details", null, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ if (req.status != 200) {
+ window.notifications.customError("myDetailsError", req.response["error"]);
+ return;
+ }
+ const details: MyDetails = req.response as MyDetails;
+ window.jellyfinID = details.id;
+ window.username = details.username;
+ let innerHTML = `
+ ${window.lang.strings("welcomeUser").replace("{user}", window.username)}
+ `;
+ if (details.admin) {
+ innerHTML += `${window.lang.strings("admin")}`;
+ }
+ if (details.disabled) {
+ innerHTML += `${window.lang.strings("disabled")}`;
+ }
+
+ rootCard.querySelector(".heading").innerHTML = innerHTML;
+
+ 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, 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, method.required);
+ }
+ }
+
+ 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");
+ messageCard.id = "card-message";
+ contactCard.parentElement.insertBefore(messageCard, contactCard);
+ }
+ if (!messageCard.textContent) {
+ messageCard.innerHTML = `
+ ${window.lang.strings("customMessagePlaceholderHeader")} ✏️
+ ${window.lang.strings("customMessagePlaceholderContent")}
+ `;
+ }
+ }
+
+ if (typeof(messageCard) != "undefined" && messageCard != null) {
+ setBestRowSpan(messageCard, false);
+ // contactCard.querySelector(".content").classList.add("h-100");
+ } else if (!statusCard.classList.contains("unfocused")) {
+ setBestRowSpan(passwordCard, true);
+ }
+ }
+ });
+});
+
+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"));
+};
+
+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++) {
+ // 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;
+}
+
+login.bindLogout(document.getElementById("logout-button"));
+
+login.login("", "");
diff --git a/user-auth.go b/user-auth.go
new file mode 100644
index 0000000..40ea057
--- /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("user-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, "user-refresh")
+ 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("user-refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", gc.Request.URL.Hostname(), true, true)
+ gc.JSON(200, getTokenDTO{jwt})
+}
diff --git a/views.go b/views.go
index ac07c91..c7a0a33 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"
)
@@ -44,15 +45,23 @@ func gcHTML(gc *gin.Context, code int, file string, templ gin.H) {
gc.HTML(code, file, templ)
}
-func (app *appContext) pushResources(gc *gin.Context, admin bool) {
+func (app *appContext) pushResources(gc *gin.Context, page Page) {
+ var toPush []string
+ switch page {
+ case AdminPage:
+ toPush = []string{"/js/admin.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/tabs.js", "/js/invites.js", "/js/accounts.js", "/js/settings.js", "/js/profiles.js", "/js/common.js"}
+ break
+ case UserPage:
+ toPush = []string{"/js/user.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/common.js"}
+ break
+ default:
+ toPush = []string{}
+ }
if pusher := gc.Writer.Pusher(); pusher != nil {
app.debug.Println("Using HTTP2 Server push")
- if admin {
- toPush := []string{"/js/admin.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/tabs.js", "/js/invites.js", "/js/accounts.js", "/js/settings.js", "/js/profiles.js", "/js/common.js"}
- for _, f := range toPush {
- if err := pusher.Push(app.URLBase+f, nil); err != nil {
- app.debug.Printf("Failed HTTP2 ServerPush of \"%s\": %+v", f, err)
- }
+ for _, f := range toPush {
+ if err := pusher.Push(app.URLBase+f, nil); err != nil {
+ app.debug.Printf("Failed HTTP2 ServerPush of \"%s\": %+v", f, err)
}
}
}
@@ -65,6 +74,8 @@ const (
AdminPage Page = iota + 1
FormPage
PWRPage
+ UserPage
+ OtherPage
)
func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string {
@@ -77,8 +88,8 @@ func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string
gc.SetCookie("lang", lang, (365 * 3600), "/", gc.Request.URL.Hostname(), true, true)
return lang
}
- case FormPage:
- if _, ok := app.storage.lang.Form[lang]; ok {
+ case FormPage, UserPage:
+ if _, ok := app.storage.lang.User[lang]; ok {
gc.SetCookie("lang", lang, (365 * 3600), "/", gc.Request.URL.Hostname(), true, true)
return lang
}
@@ -95,8 +106,8 @@ func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string
if _, ok := app.storage.lang.Admin[cookie]; ok {
return cookie
}
- case FormPage:
- if _, ok := app.storage.lang.Form[cookie]; ok {
+ case FormPage, UserPage:
+ if _, ok := app.storage.lang.User[cookie]; ok {
return cookie
}
case PWRPage:
@@ -109,7 +120,7 @@ func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string
}
func (app *appContext) AdminPage(gc *gin.Context) {
- app.pushResources(gc, true)
+ app.pushResources(gc, AdminPage)
lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang)
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
@@ -146,9 +157,78 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"jellyfinLogin": app.jellyfinLogin,
"jfAdminOnly": jfAdminOnly,
"jfAllowAll": jfAllowAll,
+ "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
})
}
+func (app *appContext) MyUserPage(gc *gin.Context) {
+ app.pushResources(gc, UserPage)
+ lang := app.getLang(gc, UserPage, app.storage.lang.chosenUserLang)
+ 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)
+ 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,
+ "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),
+ "strings": app.storage.lang.User[lang].Strings,
+ "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
+ 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 != ""
+ }
+
+ 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)
+}
+
func (app *appContext) ResetPassword(gc *gin.Context) {
isBot := strings.Contains(gc.Request.Header.Get("User-Agent"), "Bot")
setPassword := app.config.Section("password_resets").Key("set_password").MustBool(false)
@@ -157,7 +237,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
app.NoRouteHandler(gc)
return
}
- app.pushResources(gc, false)
+ app.pushResources(gc, PWRPage)
lang := app.getLang(gc, PWRPage, app.storage.lang.chosenPWRLang)
data := gin.H{
"urlBase": app.getURLBase(gc),
@@ -177,8 +257,8 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
data["validate"] = app.config.Section("password_validation").Key("enabled").MustBool(false)
data["requirements"] = app.validator.getCriteria()
data["strings"] = app.storage.lang.PasswordReset[lang].Strings
- data["validationStrings"] = app.storage.lang.Form[lang].validationStringsJSON
- data["notifications"] = app.storage.lang.Form[lang].notificationsJSON
+ data["validationStrings"] = app.storage.lang.User[lang].validationStringsJSON
+ data["notifications"] = app.storage.lang.User[lang].notificationsJSON
data["langName"] = lang
data["passwordReset"] = true
data["telegramEnabled"] = false
@@ -266,9 +346,10 @@ 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{
+ "urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
@@ -300,9 +381,10 @@ 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{
+ "urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
@@ -319,8 +401,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
}
@@ -329,7 +410,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
@@ -396,9 +477,10 @@ 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{
+ "urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
@@ -422,14 +504,15 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) {
}
func (app *appContext) InviteProxy(gc *gin.Context) {
- app.pushResources(gc, false)
+ app.pushResources(gc, FormPage)
code := gc.Param("invCode")
- lang := app.getLang(gc, FormPage, app.storage.lang.chosenFormLang)
+ 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{
+ "urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
@@ -437,23 +520,27 @@ 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
- }
- }
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(),
})
}
- if !validKey {
+ 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
}
@@ -464,26 +551,17 @@ 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)
+ 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
}
@@ -492,22 +570,25 @@ 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,
- "strings": app.storage.lang.Form[lang].Strings,
+ "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(),
"jfLink": jfLink,
})
}
- inv, ok := app.storage.invites[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(invKeys, key)
+ app.confirmationKeysLock.Lock()
+ app.ConfirmationKeys[code] = invKeys
+ app.confirmationKeysLock.Unlock()
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 = ""
}
@@ -515,6 +596,12 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true)
matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true)
+ userPageAddress := app.config.Section("invite_emails").Key("url_base").String()
+ if userPageAddress == "" {
+ userPageAddress = app.config.Section("password_resets").Key("url_base").String()
+ }
+ userPageAddress += "/my/account"
+
data := gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
@@ -528,9 +615,9 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"requirements": app.validator.getCriteria(),
"email": email,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
- "strings": app.storage.lang.Form[lang].Strings,
- "validationStrings": app.storage.lang.Form[lang].validationStringsJSON,
- "notifications": app.storage.lang.Form[lang].notificationsJSON,
+ "strings": app.storage.lang.User[lang].Strings,
+ "validationStrings": app.storage.lang.User[lang].validationStringsJSON,
+ "notifications": app.storage.lang.User[lang].notificationsJSON,
"code": code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry,
@@ -538,7 +625,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"userExpiryDays": inv.UserDays,
"userExpiryHours": inv.UserHours,
"userExpiryMinutes": inv.UserMinutes,
- "userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"),
+ "userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang,
"passwordReset": false,
"telegramEnabled": telegram,
@@ -548,6 +635,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"captcha": app.config.Section("captcha").Key("enabled").MustBool(false),
"reCAPTCHA": app.config.Section("captcha").Key("recaptcha").MustBool(false),
"reCAPTCHASiteKey": app.config.Section("captcha").Key("recaptcha_site_key").MustString(""),
+ "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
+ "userPageAddress": userPageAddress,
}
if telegram {
data["telegramPIN"] = app.telegram.NewAuthToken()
@@ -563,7 +652,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
data["discordPIN"] = app.discord.NewAuthToken()
data["discordUsername"] = app.discord.username
data["discordRequired"] = app.config.Section("discord").Key("required").MustBool(false)
- data["discordSendPINMessage"] = template.HTML(app.storage.lang.Form[lang].Strings.template("sendPINDiscord", tmpl{
+ 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,
}))
@@ -579,8 +668,9 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
}
func (app *appContext) NoRouteHandler(gc *gin.Context) {
- app.pushResources(gc, false)
+ 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(),