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 }} + +{{ end }} +{{ if .telegramEnabled }} + +{{ end }} +{{ if .matrixEnabled }} + +{{ end }} diff --git a/html/admin.html b/html/admin.html index a02b44f..b062065 100644 --- a/html/admin.html +++ b/html/admin.html @@ -22,17 +22,7 @@ {{ template "header.html" . }} - + {{ template "login-modal.html" . }} +
+ {{ .strings.myAccount }} +
diff --git a/html/create-success.html b/html/create-success.html index 4988e8e..c4e87d7 100644 --- a/html/create-success.html +++ b/html/create-success.html @@ -1,7 +1,7 @@ - + {{ template "header.html" . }} {{ .strings.successHeader }} - jfa-go diff --git a/html/form-base.html b/html/form-base.html index b5f2e2a..ba8c318 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -29,6 +29,8 @@ window.captcha = {{ .captcha }}; window.reCAPTCHA = {{ .reCAPTCHA }}; window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}"; + window.userPageEnabled = {{ .userPageEnabled }}; + window.userPageAddress = "{{ .userPageAddress }}"; {{ if .passwordReset }} diff --git a/html/form.html b/html/form.html index dff83e5..ae881b6 100644 --- a/html/form.html +++ b/html/form.html @@ -17,6 +17,7 @@
{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}

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

+ {{ if .userPageEnabled }}{{ end }} {{ .strings.continue }}
@@ -26,53 +27,7 @@

{{ .strings.confirmationRequiredMessage }}

- {{ if .telegramEnabled }} - - {{ end }} - {{ if .discordEnabled }} - - {{ end }} - {{ if .matrixEnabled }} - - {{ end }} + {{ template "account-linking.html" . }}
@@ -169,7 +124,7 @@
- {{ .strings.passwordRequirementsHeader }} + {{ .strings.passwordRequirementsHeader }}